diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000000000..ade159ddabcb9 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,43 @@ +name: ci + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +on: + pull_request: + push: + branches: + - main + +jobs: + test: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + + runs-on: ${{ matrix.os }} + + name: ๐Ÿงช Test + + env: + FLUTTER_STORAGE_BASE_URL: https://download.shorebird.dev + + steps: + - name: ๐Ÿ“š Git Checkout + uses: actions/checkout@v4 + with: + # Fetch all branches and tags to ensure that Flutter can determine its version + fetch-depth: 0 + + - name: ๐ŸŽฏ Setup Dart + uses: dart-lang/setup-dart@v1 + + - name: ๐Ÿ“ฆ Install Dependencies + run: | + dart pub get -C ./dev/bots + dart pub get -C ./dev/tools + + - name: ๐Ÿงช Run Tests + run: dart ./dev/bots/test.dart diff --git a/.github/workflows/cut-release-branch.yml b/.github/workflows/cut-release-branch.yml new file mode 100644 index 0000000000000..b20ae16f31f44 --- /dev/null +++ b/.github/workflows/cut-release-branch.yml @@ -0,0 +1,85 @@ +name: Cut Release Branch + +on: + workflow_dispatch: + inputs: + version: + description: 'Version string (format X.Y)' + required: true + type: string + commit_hash: + description: 'The hash of the commit to branch off of' + required: true + type: string + +jobs: + check-membership: + runs-on: ubuntu-latest + outputs: + is_member: ${{ steps.check.outputs.is_member }} + steps: + - name: Check if user is in the required team + id: check + env: + GH_TOKEN: ${{ secrets.FLUTTER_READ_ORG }} + run: | + # Use the GitHub CLI to check team membership + # Returns 204 if member, 404 if not + if gh api orgs/flutter/teams/flutter-release-eng/memberships/${{ github.actor }} --silent; then + echo "is_member=true" >> $GITHUB_OUTPUT + else + echo "::error::User ${{ github.actor }} is not a member of flutter-release-eng." + exit 1 + fi + + create-release-branch: + needs: check-membership + runs-on: ubuntu-latest + permissions: + contents: write # Required to create branches and push commits + + steps: + - name: Validate Version Format + run: | + if [[ ! "${{ inputs.version }}" =~ ^[0-9]+\.[0-9]+$ ]]; then + echo "Error: Version must be in 'X.Y' format (e.g., 3.10)." + exit 1 + fi + + - name: Checkout target commit + uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + with: + ref: ${{ inputs.commit_hash }} + + - name: Set Environment Variables + run: | + echo "BRANCH_NAME=flutter-${{ inputs.version }}-candidate.0" >> $GITHUB_ENV + + - name: Check if branch already exists + run: | + if git ls-remote --exit-code --heads origin ${{ env.BRANCH_NAME }}; then + echo "Error: Branch ${{ env.BRANCH_NAME }} already exists on remote." + exit 1 + fi + + - name: Configure Git + run: | + git config user.name "[bot] Cut Release Branch" + git config user.email "<>" + + - name: Create Branch and Version File + run: | + # Create and switch to the new branch locally + git checkout -b ${{ env.BRANCH_NAME }} + + # Create the version file + mkdir -p bin/internal/ + echo -n "${{ env.BRANCH_NAME }}" > bin/internal/release-candidate-branch.version + + # Commit the change + git add bin/internal/release-candidate-branch.version + git commit -m "Initialize release branch ${{ env.BRANCH_NAME }}" + + - name: Push new branch + run: | + git push origin ${{ env.BRANCH_NAME }} diff --git a/.github/workflows/easy-cp.yml b/.github/workflows/easy-cp.yml index 78bc1d7d67d32..39ea00a61e1a9 100644 --- a/.github/workflows/easy-cp.yml +++ b/.github/workflows/easy-cp.yml @@ -59,8 +59,8 @@ jobs: id: attempt-cp working-directory: ./flutter run: | - git config user.name "GitHub Actions Bot" - git config user.email "<>" + git config user.name "flutteractionsbot" + git config user.email "" git remote add upstream https://github.com/flutter/flutter.git git fetch upstream $RELEASE_BRANCH git fetch upstream master diff --git a/.github/workflows/merge-changelog.yml b/.github/workflows/merge-changelog.yml index 0e40867a8608c..c8e397061c541 100644 --- a/.github/workflows/merge-changelog.yml +++ b/.github/workflows/merge-changelog.yml @@ -14,45 +14,65 @@ jobs: pull-requests: write steps: + # defaults to `fetch-depth:1` which only checks out the current branch + # since the action runs on master, it will only checkout master. - name: Setup Repository uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 with: - repository: ${{ github.repository }} + repository: flutteractionsbot/flutter + token: ${{ secrets.FLUTTERACTIONSBOT_CP_TOKEN }} ref: master - name: Configure git run: | - git config user.name "[bot] Merge Changelog" - git config user.email "<>" + git config user.name "flutteractionsbot" + git config user.email "" + git remote add upstream https://github.com/flutter/flutter.git - - name: Read CHANGELOG.md from the stable branch - id: read_stable_changelog + # avoid downloading hundreds of other branches/history + # utilizing `fetch-depth:0` in actions/checkout would clone everything. + - name: Fetch upstream branches run: | - CHANGELOG_CONTENT=$(git show origin/stable:CHANGELOG.md) - echo "CHANGELOG_CONTENT<> $GITHUB_ENV - echo "$CHANGELOG_CONTENT" >> $GITHUB_ENV - echo "EOF" >> $GITHUB_ENV + git fetch upstream stable:stable --depth=1 + git fetch upstream master --depth=1 - name: Prepare PR branch and commit changes id: prepare_pr_branch + env: + GH_TOKEN: ${{ secrets.FLUTTERACTIONSBOT_CP_TOKEN }} run: | PR_BRANCH="sync-changelog-stable-to-master-$(date +%s)" echo "pr_branch_name=$PR_BRANCH" >> "$GITHUB_OUTPUT" - git checkout -b "$PR_BRANCH" master - echo "${{ env.CHANGELOG_CONTENT }}" > CHANGELOG.md + + # Base the branch on upstream's master to avoid issues if the fork's master is out of date + git checkout -b "$PR_BRANCH" upstream/master + + # Retrieve the file from stable directly into the working directory + git show stable:CHANGELOG.md > CHANGELOG.md + + # Stop if no changes are detected + if git diff --quiet CHANGELOG.md; then + echo "No changes detected. Skipping PR creation." + echo "has_changes=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + echo "has_changes=true" >> "$GITHUB_OUTPUT" git add CHANGELOG.md git commit -m "Sync CHANGELOG.md from stable" git push origin "$PR_BRANCH" - name: Create Pull Request + if: steps.prepare_pr_branch.outputs.has_changes == 'true' env: - GH_TOKEN: ${{ github.token }} + GH_TOKEN: ${{ secrets.FLUTTERACTIONSBOT_CP_TOKEN }} run: | PR_HEAD_BRANCH=${{ steps.prepare_pr_branch.outputs.pr_branch_name }} gh pr create \ --base master \ - --head "$PR_HEAD_BRANCH" \ + --head flutteractionsbot:$PR_HEAD_BRANCH \ + --repo flutter/flutter \ --title "Sync CHANGELOG.md from stable" \ --body "This PR automates the synchronization of \`CHANGELOG.md\` from the \`stable\` branch to the \`master\` branch." \ --label autosubmit diff --git a/.github/workflows/release-tracker.yml b/.github/workflows/release-tracker.yml new file mode 100644 index 0000000000000..4eca878fa49e7 --- /dev/null +++ b/.github/workflows/release-tracker.yml @@ -0,0 +1,124 @@ +name: ๐Ÿš€ Create Release Tracker +on: + workflow_dispatch: + inputs: + version: + description: 'Version (format X.Y.Z for stable or X.Y.0-0.Z.pre for beta)' + required: true + type: string + release_channel: + description: 'Release Channel' + required: true + type: choice + options: + - stable + - beta + hotfix: + description: 'Hotfix Release' + required: true + type: boolean + +permissions: + contents: read + issues: write + +jobs: + create_issue: + runs-on: ubuntu-latest + steps: + - name: Create Tracking Issue + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + with: + script: | + const version = "${{ inputs.version }}"; + const channel = "${{ inputs.release_channel }}"; + const hotfix = "${{ inputs.hotfix }}"; + const releaseType = hotfix === "true" ? "hotfix" : "initial"; + + let versionValidator; + if (channel === 'stable') { + if (hotfix === 'true') { + versionValidator = /^\d+\.\d+\.[1-9]\d*$/; + } else { + versionValidator = /^\d+\.\d+\.0$/; + } + } else if (channel === 'beta') { + if (hotfix === 'true') { + versionValidator = /^\d+\.\d+\.0-0\.[1-9]\d*\.pre$/; + } else { + versionValidator = /^\d+\.\d+\.0-0\.1\.pre$/; + } + } else { + core.setFailed(`Validation Error: Invalid channel name: ${channel}`); + } + + if (!versionValidator.test(version)) { + core.setFailed(`Validation Error: The version "${version}" is not a valid format for a ${channel} ${releaseType} release.`); + return; + } + + const majorMinorVersion = version.match(/^(\d+\.\d+)/)[0]; + const branchName = `flutter-${majorMinorVersion}-candidate.0`; + + // Build the dynamic checklist + let body = `## Flutter Release\n`; + body += `Candidate Branch: [${branchName}](https://github.com/${context.repo.owner}/${context.repo.repo}/tree/${branchName})\n`; + body += `Tag: [${version}](https://github.com/{context.repo.owner}/${context.repo.repo}/releases/tag/${version})\n`; + + body += `### ๐Ÿ“‹ Release Checklist\n\n`; + + // Add link to branches etc + + if (channel === 'beta' && hotfix !== 'true') { + body += '#### Branch Alignment\n'; + body += ' - [ ] Stop the [Dart Auto Roller](https://autoroll.skia.org/r/dart-sdk-flutter)\n'; + body += ' - [ ] Roll forward to aligned Dart hash\n'; + body += ' - Dart Hash: [Paste the Dart hash here]\n'; + body += ' - [ ] Wait for Google3 Roll to complete.\n'; + body += ' - [ ] Restart the [Dart Auto Roller](https://autoroll.skia.org/r/dart-sdk-flutter)\n'; + + body += '#### Cut release branches\n'; + body += ' - [ ] Cut new flutter branch: `' + branchName + '`\n'; + body += ' - [ ] Cut new recipes branch: `' + branchName + '` [Gerrit UI](https://flutter-review.googlesource.com/admin/repos/recipes,branches)\n'; + } + + body += '#### Update release branch\n'; + + if (channel === 'beta' && hotfix !== 'true') { + body += ' - [ ] Create `release-candidate-branch.version` file on candidate branch\n'; + } else { + body += `- [ ] Merge outstanding [cherry-picks](https://github.com/${context.repo.owner}/${context.repo.repo}/pulls?q=is%3Apr+is%3Aopen+label%3A"cp%3A+review")\n`; + body += '- [ ] Update Dart version if necessary\n'; + body += ' - Dart Hash: [Paste the Dart hash here]\n'; + } + + body += '- [ ] Pin engine hash in `engine.version` if necessary\n' + + if (channel === 'stable' && hotfix === 'true') { + body += '- [ ] Add changelog for stable release\n'; + } + + body += '#### Validate and publish release\n' + body += `- [ ] Run postsubmits and validate they are all passing on the [Flutter Dashboard](https://flutter-dashboard.appspot.com/#/build?repo=flutter&branch=${branchName})\n`; + body += '- [ ] Push release with MPA\n'; + body += '- [ ] Check for the new versions on the [Flutter SDK Archive](https://docs.flutter.dev/install/archive)\n'; + + body += '- [ ] Announce the release on Discord\n'; + body += '- [ ] Announce the release on Google internal channels\n'; + + if (channel === 'stable') { + body += '- [ ] Announce the release on flutter-announce mailing list\n'; + } + + if (channel === 'stable' && hotfix === 'true') { + body += '- [ ] Merge the changelog back to the `master` branch\n'; + } + + await github.rest.issues.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title: `[${channel}] [${releaseType}] Flutter Release Version ${version}`, + body: body, + labels: ['release-tracker'], + assignees: [context.actor], + }); diff --git a/.github/workflows/roll-dart-dependencies.yml b/.github/workflows/roll-dart-dependencies.yml new file mode 100644 index 0000000000000..b74d1727ba64f --- /dev/null +++ b/.github/workflows/roll-dart-dependencies.yml @@ -0,0 +1,110 @@ +name: Roll Dart Dependencies + +on: + workflow_dispatch: + inputs: + dart_hash: + description: 'The Dart SDK commit hash to update to' + required: true + type: string + +jobs: + update-deps: + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + + steps: + - name: Checkout Repository + uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + with: + repository: flutteractionsbot/flutter + token: ${{ secrets.FLUTTERACTIONSBOT_CP_TOKEN }} + ref: master + + - name: Configure git + env: + GH_TOKEN: ${{ secrets.FLUTTERACTIONSBOT_CP_TOKEN }} + run: | + git config user.name "flutteractionsbot" + git config user.email "" + git remote add upstream https://github.com/flutter/flutter.git + git fetch upstream ${{ github.ref_name }} + + BRANCH_NAME="sync-dart-${{ inputs.dart_hash }}" + echo "BRANCH_NAME=$BRANCH_NAME" >> $GITHUB_ENV + + git checkout -b "$BRANCH_NAME" upstream/${{ github.ref_name }} + + - name: Download and Decode Dart DEPS + id: download-deps + run: | + DART_HASH="${{ inputs.dart_hash }}" + URL="https://dart.googlesource.com/sdk/+/$DART_HASH/DEPS?format=TEXT" + + echo "Fetching DEPS from $URL" + + # Googlesource returns base64 encoded text when format=TEXT is used + RESPONSE=$(curl -s -f "$URL") + + if [ $? -ne 0 ]; then + echo "::error::Failed to download DEPS file. Please verify the Dart hash: $DART_HASH" + exit 1 + fi + + echo "$RESPONSE" | base64 --decode > dart_deps_file + + if [ ! -s dart_deps_file ]; then + echo "::error::Downloaded DEPS file is empty or decoding failed." + exit 1 + fi + + - name: Run Update Script + id: run-script + run: | + DART_HASH="${{ inputs.dart_hash }}" + DART_DEPS="dart_deps_file" + FLUTTER_DEPS="DEPS" # Root of the repository + SCRIPT="engine/src/tools/dart/create_updated_flutter_deps.py" + + if [ ! -f "$SCRIPT" ]; then + echo "::error::Update script not found at $SCRIPT" + exit 1 + fi + + echo "Running $SCRIPT..." + + # Execute the python script + if ! python3 "$SCRIPT" -d "$DART_DEPS" -f "$FLUTTER_DEPS" -r "$DART_HASH"; then + echo "::error::The python update script failed during execution." + exit 1 + fi + + - name: Check for Changes + id: git-check + run: | + # Check if the root DEPS file has changed + if git diff --exit-code DEPS; then + echo "changed=false" >> $GITHUB_OUTPUT + echo "### No-op Success" >> $GITHUB_STEP_SUMMARY + echo "The DEPS file is already up to date with Dart hash \`${{ inputs.dart_hash }}\`." >> $GITHUB_STEP_SUMMARY + else + echo "changed=true" >> $GITHUB_OUTPUT + fi + + - name: Create PR if Changed + if: steps.git-check.outputs.changed == 'true' + env: + GH_TOKEN: ${{ secrets.FLUTTERACTIONSBOT_CP_TOKEN }} + run: | + git add DEPS + git commit -m "Update flutter DEPS to dart ${{ inputs.dart_hash }}" + git push origin "$BRANCH_NAME" --force + + gh pr create \ + --title "[${{ github.ref_name }}] Update Flutter DEPS to Dart ${{ inputs.dart_hash }}" \ + --body "This PR updates the transitive dependencies in the engine \`DEPS\` file based on Dart SDK hash \`${{ inputs.dart_hash }}\`." \ + --repo flutter/flutter \ + --base ${{ github.ref_name }} \ + --head flutteractionsbot:$BRANCH_NAME diff --git a/.github/workflows/shorebird_ci.yml b/.github/workflows/shorebird_ci.yml new file mode 100644 index 0000000000000..526c04eb1e53c --- /dev/null +++ b/.github/workflows/shorebird_ci.yml @@ -0,0 +1,143 @@ +name: shorebird_ci + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +on: + pull_request: + push: + branches: + - shorebird/dev + +jobs: + # NOTE: Windows is not included because shorebird_tests depends on + # flutter_flavorizr which requires Xcode. Additionally, flutter_tools + # tests on Windows would need additional setup work. + + flutter-tools-tests: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + + runs-on: ${{ matrix.os }} + + name: ๐Ÿ› ๏ธ Flutter Tools Tests (${{ matrix.os }}) + + steps: + - name: ๐Ÿ“š Git Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: ๐ŸŽฏ Setup Dart + uses: dart-lang/setup-dart@v1 + + - name: ๐Ÿฆ Run Flutter Tools Tests + run: ../../bin/flutter test test/general.shard + working-directory: packages/flutter_tools + + shard-runner-tests: + runs-on: ubuntu-latest + + name: ๐Ÿงฉ Shard Runner Tests + + defaults: + run: + working-directory: shorebird/ci/shard_runner + + steps: + - name: ๐Ÿ“š Git Checkout + uses: actions/checkout@v4 + + - name: ๐ŸŽฏ Setup Dart + uses: dart-lang/setup-dart@v1 + + - name: ๐Ÿ“ฆ Install Dependencies + run: dart pub get + + - name: ๐Ÿ” Check Formatting + run: dart format --output=none --set-exit-if-changed . + + - name: ๐Ÿ”Ž Analyze + run: dart analyze --fatal-infos + + - name: ๐Ÿงช Run Tests + run: dart test + + shorebird-android-tests: + # Android tests run on Ubuntu (faster runners, no macOS needed) + runs-on: ubuntu-latest + + name: ๐Ÿค– Shorebird Android Tests + + steps: + - name: ๐Ÿ“š Git Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: ๐ŸŽฏ Setup Dart + uses: dart-lang/setup-dart@v1 + + - uses: actions/setup-java@v4 + with: + distribution: "zulu" + java-version: "17" + + - name: ๐Ÿ“ฆ Cache Gradle + uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: | + ${{ runner.os }}-gradle- + + - name: ๐Ÿค– Run Android Tests + run: dart test test/base_test.dart test/android_test.dart + working-directory: packages/shorebird_tests + + shorebird-ios-tests: + # iOS tests require macOS for Xcode + runs-on: macos-latest + + name: ๐ŸŽ Shorebird iOS + Android Smoke Tests + + steps: + - name: ๐Ÿ“š Git Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: ๐ŸŽฏ Setup Dart + uses: dart-lang/setup-dart@v1 + + - uses: actions/setup-java@v4 + with: + distribution: "zulu" + java-version: "17" + + - name: ๐Ÿ“ฆ Cache Gradle + uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: | + ${{ runner.os }}-gradle- + + - name: ๐ŸŽ Run iOS Tests + run: dart test test/base_test.dart test/ios_test.dart + working-directory: packages/shorebird_tests + + - name: ๐Ÿค– Run Android Smoke Test (macOS) + # Quick sanity check that Android builds work on macOS too + run: dart test test/android_test.dart --name "can build an apk" + working-directory: packages/shorebird_tests + env: + # Enables streaming subprocess output for debugging timeouts. + VERBOSE: "1" diff --git a/.github/workflows/sync-engine-version.yml b/.github/workflows/sync-engine-version.yml new file mode 100644 index 0000000000000..9219999f25c7e --- /dev/null +++ b/.github/workflows/sync-engine-version.yml @@ -0,0 +1,82 @@ +name: Sync Engine Version + +on: + workflow_dispatch: + +jobs: + sync-engine: + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + + steps: + - name: Checkout Repository + uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + with: + repository: flutteractionsbot/flutter + token: ${{ secrets.FLUTTERACTIONSBOT_CP_TOKEN }} + ref: master + + - name: Configure git + env: + GH_TOKEN: ${{ secrets.FLUTTERACTIONSBOT_CP_TOKEN }} + run: | + git config user.name "flutteractionsbot" + git config user.email "" + git remote add upstream https://github.com/flutter/flutter.git + git fetch upstream ${{ github.ref_name }} + + BRANCH_NAME="update-engine-$(date +%s)" + echo "BRANCH_NAME=$BRANCH_NAME" >> $GITHUB_ENV + + git checkout -b "$BRANCH_NAME" upstream/${{ github.ref_name }} + + - name: Generate and Capture Hash + id: generate-hash + run: | + chmod +x bin/internal/last_engine_commit.sh + + # Run the script and capture the output + ENGINE_HASH=$(./bin/internal/last_engine_commit.sh) + + # Save to the version file + echo "$ENGINE_HASH" > bin/internal/engine.version + + # Export to environment for later steps + echo "ENGINE_HASH=$ENGINE_HASH" >> $GITHUB_ENV + + # Log it to the Action summary for easy viewing + echo "### Generated Engine Hash: \`$ENGINE_HASH\`" >> $GITHUB_STEP_SUMMARY + + - name: Check for Changes + id: git-check + run: | + # -N (intent-to-add) treats untracked files as existing but empty + # -f adds even if it's in the .gitignore (which it usually is) + git add -N -f bin/internal/engine.version + + if git diff --exit-code bin/internal/engine.version; then + echo "changed=false" >> $GITHUB_OUTPUT + echo "No changes detected. Skipping PR." >> $GITHUB_STEP_SUMMARY + else + echo "changed=true" >> $GITHUB_OUTPUT + fi + + - name: Create PR if Changed + if: steps.git-check.outputs.changed == 'true' + env: + GH_TOKEN: ${{ secrets.FLUTTERACTIONSBOT_CP_TOKEN }} + run: | + # Make a commit and push to the branch. + git add bin/internal/engine.version + git commit -m "Update engine.version to $(cat bin/internal/engine.version)" + git push origin "$BRANCH_NAME" + + # Use the GitHub CLI (pre-installed on runners) to create the PR + gh pr create \ + --title "[${{ github.ref_name }}] Sync engine.version to ${{ env.ENGINE_HASH }}" \ + --body "Updates the engine.version file." \ + --repo flutter/flutter \ + --base ${{ github.ref_name }} \ + --head flutteractionsbot:$BRANCH_NAME diff --git a/CHANGELOG.md b/CHANGELOG.md index 43d50d5d4674a..d03123f11719d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,8 +30,83 @@ More information and tips: docs/releases/Hotfix-Documentation-Best-Practices.md --> +## Flutter 3.41 Changes + +### [3.41.9](https://github.com/flutter/flutter/releases/tag/3.41.9) +- [flutter/185621](https://github.com/flutter/flutter/pull/185621) Fixes a potential integer overflow that can happen when handling some animated PNG files. + +### [3.41.8](https://github.com/flutter/flutter/releases/tag/3.41.8) +- [flutter/185150](https://github.com/flutter/flutter/issues/185150) When using profile mode on a physical iOS device, the app may fail to connect to the Dart VM. + +### [3.41.7](https://github.com/flutter/flutter/releases/tag/3.41.7) +- [flutter/184376](https://github.com/flutter/flutter/issues/184376) When building an iOS or macOS app, the build may fail due to git multi-pack-index error. +- [flutter/184254](https://github.com/flutter/flutter/issues/184254) When debugging on physical iOS devices and Xcode 26.4+, app often crashes. +- [flutter/184689](https://github.com/flutter/flutter/issues/184689) When using an ffi Windows package, don't require a plugin class. + +### [3.41.6](https://github.com/flutter/flutter/releases/tag/3.41.6) +- [flutter/184025](https://github.com/flutter/flutter/pull/184025) Include a fix from Skia that ensures that the correct atlas for the glyph mask format is used consistently. +- [flutter/182708](https://github.com/flutter/flutter/issues/182708) Visual issues with circles appearing jagged. Especially on thin stroked circles and circles with small radii. +- [flutter/183887](https://github.com/flutter/flutter/issues/183887) During SCREEN_OFF event a deadlock preventing new frames causing an ANR can occur on android devices running the Android 16 March Security update. + +### [3.41.5](https://github.com/flutter/flutter/releases/tag/3.41.5) +- [flutter/182708](https://github.com/flutter/flutter/issues/182708) When using Impeller on any platform, bur artifacts in circles rendering at 45 degree angles. + +### [3.41.4](https://github.com/flutter/flutter/releases/tag/3.41.4) +- [flutter/182748](https://github.com/flutter/flutter/issues/182748) When building for an iOS simulator with Xcode 26, the build will fail when there is a CocoaPod dependency that does not support arm. +- [flutter/182361](https://github.com/flutter/flutter/issues/182361) When iOS plugins register to receive lifecycle events during an event, a crash may occur. +- [flutter/182367](https://github.com/flutter/flutter/issues/182367) Crash on Flutter Web Skwasm apps. +- [flutter/183071](https://github.com/flutter/flutter/issues/183071) Updated test package and related dependencies. + +### [3.41.3](https://github.com/flutter/flutter/releases/tag/3.41.3) + +- [flutter/182501](https://github.com/flutter/flutter/issues/182501) Reduce CPU utilization of idle Flutter Windows apps. +- [flutter/182233](https://github.com/flutter/flutter/issues/182233) Tapping on the status bar may crash the app on iOS when there's a primary scroll view that has never been laid out. + +### [3.41.2](https://github.com/flutter/flutter/releases/tag/3.41.2) + +- [flutter/179673](https://github.com/flutter/flutter/issues/179673) When content sizing is not enabled on Android, a race condition can sometimes make platform views not render correctly. +- [flutter/182076](https://github.com/flutter/flutter/issues/182076) Fix flutter build web ignoring --web-define flag +- [flutter/182243](https://github.com/flutter/flutter/issues/182243) Don't throw an exception if no web define variable is set. +- [flutter/182292](https://github.com/flutter/flutter/issues/182292) Fix bug in multisurfacerenderer where canvases do not have "position: absolute" + +### [3.41.1](https://github.com/flutter/flutter/releases/tag/3.41.1) + +- [flutter/182314](https://github.com/flutter/flutter/issues/182314) Test coverage is broken due to pinned `test_api` version in `flutter_test`. +- [flutter/182335](https://github.com/flutter/flutter/issues/182335) Version 1.29 of the Dart test package adds a blank line to test output causing unexpected test failures. + +### [3.41.0](https://github.com/flutter/flutter/releases/tag/3.41.0) + +Learn about what's new in this release in [the blog post](https://blog.flutter.dev/whats-new-in-flutter-3-41-302ec140e632), and check out the [CHANGELOG](https://docs.flutter.dev/release/release-notes/release-notes-3.41.0) for a detailed list of all the new changes. + + ## Flutter 3.38 Changes +### [3.38.10](https://github.com/flutter/flutter/releases/tag/3.38.10) + +- [flutter/181607](https://github.com/flutter/flutter/pull/181607) When using an ffi plugin on macOS, generated frameworks have the wrong structure. + +### [3.38.9](https://github.com/flutter/flutter/releases/tag/3.38.9) + +- [flutter/181568](https://github.com/flutter/flutter/pull/181568) Update Dart to 3.10.8. + +### [3.38.8](https://github.com/flutter/flutter/releases/tag/3.38.8) + +- [flutter/178151](https://github.com/flutter/flutter/issues/178151) - `flutter run -d chrome` may crash with a `DartDevelopmentServiceException` when the application shuts down during the startup sequence. + +### [3.38.7](https://github.com/flutter/flutter/releases/tag/3.38.7) + +- [flutter/179857](https://github.com/flutter/flutter/issues/179857) - `flutter run -d all` crashes if multiple devices are available. + +### [3.38.6](https://github.com/flutter/flutter/releases/tag/3.38.6) + +- [flutter/179139](https://github.com/flutter/flutter/issues/179139) - `flutter widget-preview start` creates new cached build artifacts on each run, resulting in increasing disk usage after each run. +- [flutter/178896](https://github.com/flutter/flutter/issues/178896) - Apps crash during launch on Windows when run from paths containing non-ASCII characters. +- [flutter/176943](https://github.com/flutter/flutter/issues/176943) - Configuration changes to run tests on macOS 15 or 15.7.2 for Flutter's CI. +- [flutter/179914](https://github.com/flutter/flutter/issues/179914) - Flutter Android apps that upgrade to AGP 9.0.0 require migration steps. +- [flutter/175099](https://github.com/flutter/flutter/issues/175099) - When WebViews are scrolled on iOS 26, they become unclickable. +- [flutter/175074](https://github.com/flutter/flutter/issues/175074) - When the virtual keyboard is closed on Android web, the area behind it remains blank and the app only draws in the area that used to be above the keyboard. +- [flutter/180381](https://github.com/flutter/flutter/issues/180381) - Apps crash on Android when enabling accessibility, hiding a platform view, and pulling out the top curtain. + ### [3.38.5](https://github.com/flutter/flutter/releases/tag/3.38.5) - [flutter/179700](https://github.com/flutter/flutter/issues/179700) Update dart to 3.10.4. diff --git a/DEPS b/DEPS index 41ab9c559a1b1..0d19f842c7d76 100644 --- a/DEPS +++ b/DEPS @@ -15,7 +15,11 @@ vars = { 'flutter_git': 'https://flutter.googlesource.com', 'skia_git': 'https://skia.googlesource.com', 'llvm_git': 'https://llvm.googlesource.com', - 'skia_revision': '837be28dd21818d2169e1e6789e89e771a55f01d', + 'skia_revision': 'a183ded9ad67d998a5b0fe4cd86d3ef5402ffb45', + "dart_sdk_revision": "3f8b97e369a83033089608c86c996a3f67897f8c", + "dart_sdk_git": "git@github.com:shorebirdtech/dart-sdk.git", + "updater_git": "https://github.com/shorebirdtech/updater.git", + "updater_rev": "ede7990ea2f792a9296ff533d7f6f39637f1f86d", # WARNING: DO NOT EDIT canvaskit_cipd_instance MANUALLY # See `lib/web_ui/README.md` for how to roll CanvasKit to a new version. @@ -59,15 +63,15 @@ vars = { # updated revision list of existing dependencies. You will need to # gclient sync before and after update deps to ensure all deps are updated. # updated revision list of existing dependencies. - 'dart_revision': 'fe2ba2c5dd503f2223b08ef6ccf0344ddf537273', + 'dart_revision': 'c70f78e7d682c158c15ca0c26c729b3ccb932284', # WARNING: DO NOT EDIT MANUALLY # The lines between blank lines above and below are generated by a script. See create_updated_flutter_deps.py - 'dart_ai_rev': 'a3a196bf4773c7a7a9f93b6798232fb0d8220bc3', + 'dart_ai_rev': '1e69aa896fa23c93458017ee9b0bfacf446cb616', 'dart_binaryen_rev': '6ec7b5f9c615d3b224c67ae221d6812c8f8e1a96', 'dart_boringssl_rev': '9f138d05879fcf61965d1ea9d6c8b2cfc8bc12cb', 'dart_core_rev': 'cbb485437c61d37753bcc98818beca54d5b38f69', - 'dart_devtools_rev': 'b9d7fc1a4119b3d214a77939f9d75b0c0b25d36a', + 'dart_devtools_rev': '9be2c887e3982e519cf58f185d5f7b008a4606e9', 'dart_ecosystem_rev': 'eac66d93142907b39f2271647c111f36ff3365b9', 'dart_http_rev': 'a22386e9c390290c916d1c53a3d3c1447ec120ce', 'dart_i18n_rev': 'dd8a792a8492370a594706c8304d2eb8db844d7a', @@ -75,7 +79,7 @@ vars = { 'dart_perfetto_rev': '13ce0c9e13b0940d2476cd0cff2301708a9a2e2b', 'dart_protobuf_gn_rev': 'ca669f79945418f6229e4fef89b666b2a88cbb10', 'dart_protobuf_rev': '9e30258e0aa6a6430ee36c84b75308a9702fde42', - 'dart_pub_rev': 'b21ac685bc64f6e81050ec0093aa83543d66e2fd', + 'dart_pub_rev': '30b29f1cad33a772fa58692ce109ad412748d78e', 'dart_sync_http_rev': '6666fff944221891182e1f80bf56569338164d72', 'dart_tools_rev': '87270e60a5c92f127acb29d6e0dbc2d920c3f669', 'dart_vector_math_rev': '70a9a2cb610d040b247f3ca2cd70a94c1c6f6f23', @@ -294,7 +298,7 @@ deps = { # Var('flutter_git') + '/third_party/protobuf-gn' + '@' + Var('dart_protobuf_gn_rev'), 'engine/src/flutter/third_party/dart': - Var('dart_git') + '/sdk.git' + '@' + Var('dart_revision'), + Var('dart_sdk_git') + '@' + Var('dart_sdk_revision'), # WARNING: Unused Dart dependencies in the list below till "WARNING:" marker are removed automatically - see create_updated_flutter_deps.py. @@ -302,7 +306,7 @@ deps = { Var('chromium_git') + '/external/github.com/WebAssembly/binaryen.git' + '@' + Var('dart_binaryen_rev'), 'engine/src/flutter/third_party/dart/third_party/devtools': - {'dep_type': 'cipd', 'packages': [{'package': 'dart/third_party/flutter/devtools', 'version': 'git_revision:b9d7fc1a4119b3d214a77939f9d75b0c0b25d36a'}]}, + {'dep_type': 'cipd', 'packages': [{'package': 'dart/third_party/flutter/devtools', 'version': 'git_revision:9be2c887e3982e519cf58f185d5f7b008a4606e9'}]}, 'engine/src/flutter/third_party/dart/third_party/perfetto/src': Var('android_git') + '/platform/external/perfetto' + '@' + Var('dart_perfetto_rev'), @@ -491,11 +495,14 @@ deps = { 'engine/src/flutter/third_party/ocmock': Var('flutter_git') + '/third_party/ocmock' + '@' + Var('ocmock_rev'), + 'engine/src/flutter/third_party/updater': + Var('updater_git') + '@' + Var('updater_rev'), + 'engine/src/flutter/third_party/libjpeg-turbo/src': Var('flutter_git') + '/third_party/libjpeg-turbo' + '@' + '0fb821f3b2e570b2783a94ccd9a2fb1f4916ae9f', 'engine/src/flutter/third_party/libpng': - Var('flutter_git') + '/third_party/libpng' + '@' + 'de36b892e921c684ef718fec24739ae9bb49c977', + Var('flutter_git') + '/third_party/libpng' + '@' + 'f139fd5d80944f5453b079672e50f32ca98ef076', 'engine/src/flutter/third_party/libwebp': Var('chromium_git') + '/webm/libwebp.git' + '@' + 'ca332209cb5567c9b249c86788cb2dbf8847e760', # 1.3.2 diff --git a/bin/internal/content_aware_hash.sh b/bin/internal/content_aware_hash.sh index b440b5c133de0..7382f70ecf44a 100755 --- a/bin/internal/content_aware_hash.sh +++ b/bin/internal/content_aware_hash.sh @@ -68,4 +68,10 @@ if [[ "$CURRENT_BRANCH" != "main" && \ fi fi -git -C "$FLUTTER_ROOT" ls-tree "$BASEREF" -- "${TRACKEDFILES[@]}" | git hash-object --stdin +# Workaround for Xcode's git multi-pack-index incompatibility. +# https://github.com/flutter/flutter/issues/184376 +GIT_OPTS=() +if [[ "$(git --version)" == *"Apple Git"* ]]; then + GIT_OPTS=(-c core.multiPackIndex=false) +fi +git "${GIT_OPTS[@]}" -C "$FLUTTER_ROOT" ls-tree "$BASEREF" -- "${TRACKEDFILES[@]}" | git hash-object --stdin diff --git a/bin/internal/engine.version b/bin/internal/engine.version new file mode 100644 index 0000000000000..80641c462a872 --- /dev/null +++ b/bin/internal/engine.version @@ -0,0 +1 @@ +fbecf6f992baeda515089cfa15a0f9998e60f030 diff --git a/bin/internal/release-candidate-branch.version b/bin/internal/release-candidate-branch.version new file mode 100644 index 0000000000000..5af82a71fdb68 --- /dev/null +++ b/bin/internal/release-candidate-branch.version @@ -0,0 +1 @@ +flutter-3.41-candidate.0 diff --git a/bin/internal/update_dart_sdk.ps1 b/bin/internal/update_dart_sdk.ps1 index 318ef62add853..a588137834eb6 100644 --- a/bin/internal/update_dart_sdk.ps1 +++ b/bin/internal/update_dart_sdk.ps1 @@ -41,7 +41,7 @@ if ((Test-Path $engineStamp) -and ($engineVersion -eq (Get-Content $engineStamp) $dartSdkBaseUrl = $Env:FLUTTER_STORAGE_BASE_URL if (-not $dartSdkBaseUrl) { - $dartSdkBaseUrl = "https://storage.googleapis.com" + $dartSdkBaseUrl = "https://download.shorebird.dev" } if ($engineRealm) { $dartSdkBaseUrl = "$dartSdkBaseUrl/$engineRealm" diff --git a/bin/internal/update_dart_sdk.sh b/bin/internal/update_dart_sdk.sh index 9ab6151021a8b..6e3617a328342 100755 --- a/bin/internal/update_dart_sdk.sh +++ b/bin/internal/update_dart_sdk.sh @@ -123,7 +123,7 @@ if [ ! -f "$ENGINE_STAMP" ] || [ "$ENGINE_VERSION" != `cat "$ENGINE_STAMP"` ]; t FIND=find fi - DART_SDK_BASE_URL="${FLUTTER_STORAGE_BASE_URL:-https://storage.googleapis.com}${ENGINE_REALM:+/$ENGINE_REALM}" + DART_SDK_BASE_URL="${FLUTTER_STORAGE_BASE_URL:-https://download.shorebird.dev}${ENGINE_REALM:+/$ENGINE_REALM}" DART_SDK_URL="$DART_SDK_BASE_URL/flutter_infra_release/flutter/$ENGINE_VERSION/$DART_ZIP_NAME" # if the sdk path exists, copy it to a temporary location diff --git a/dev/bots/post_process_docs.dart b/dev/bots/post_process_docs.dart index aaf08e82e4913..5c40a60cd4f3b 100644 --- a/dev/bots/post_process_docs.dart +++ b/dev/bots/post_process_docs.dart @@ -38,7 +38,7 @@ Future postProcess() async { await runProcessWithValidations([ 'curl', '-L', - 'https://storage.googleapis.com/flutter_infra_release/flutter/$revision/api_docs.zip', + 'https://download.shorebird.dev/flutter_infra_release/flutter/$revision/api_docs.zip', '--output', zipDestination, '--fail', diff --git a/dev/bots/test.dart b/dev/bots/test.dart index 55ac813060fb8..e440a2dc86b8d 100644 --- a/dev/bots/test.dart +++ b/dev/bots/test.dart @@ -251,7 +251,7 @@ Future _runIntegrationToolTests() async { Future _runWidgetPreviewScaffoldToolTests() async { await runFlutterTest( - path.join(_toolsPath, 'test', 'widget_preview_scaffold.shard', 'widget_preview_scaffold'), + path.join(flutterRoot, 'dev', 'integration_tests', 'widget_preview_scaffold'), ); } diff --git a/dev/bots/unpublish_package.dart b/dev/bots/unpublish_package.dart index 022111bd16ed9..03cc3a24d2fab 100644 --- a/dev/bots/unpublish_package.dart +++ b/dev/bots/unpublish_package.dart @@ -23,7 +23,7 @@ import 'package:process/process.dart'; const String gsBase = 'gs://flutter_infra_release'; const String releaseFolder = '/releases'; const String gsReleaseFolder = '$gsBase$releaseFolder'; -const String baseUrl = 'https://storage.googleapis.com/flutter_infra_release'; +const String baseUrl = 'https://download.shorebird.dev/flutter_infra_release'; /// Exception class for when a process fails to run, so we can catch /// it and provide something more readable than a stack trace. diff --git a/dev/devicelab/bin/tasks/flutter_test_performance.dart b/dev/devicelab/bin/tasks/flutter_test_performance.dart index 6dd25bc854d3b..a4f322ea3b3a4 100644 --- a/dev/devicelab/bin/tasks/flutter_test_performance.dart +++ b/dev/devicelab/bin/tasks/flutter_test_performance.dart @@ -65,16 +65,15 @@ Future runTest({bool coverage = false, bool noPub = false}) async { // ignore this line step = TestStep.runningPubGet; } else if (step.index <= TestStep.testWritesFirstCarriageReturn.index && entry.trim() == '') { - // we have a blank line at the start + // flutter_tools will print a blank line at the start of the test when using some versions + // of the Dart test package. In test package version 1.29 this line is no longer present. step = TestStep.testWritesFirstCarriageReturn; } else { final Match? match = testOutputPattern.matchAsPrefix(entry); if (match == null) { badLines += 1; } else { - if (step.index >= TestStep.testWritesFirstCarriageReturn.index && - step.index <= TestStep.testLoading.index && - match.group(1)!.startsWith('loading ')) { + if (step.index <= TestStep.testLoading.index && match.group(1)!.startsWith('loading ')) { // first the test loads step = TestStep.testLoading; } else if (step.index <= TestStep.testRunning.index && diff --git a/dev/devicelab/bin/tasks/ios_debug_workflow.dart b/dev/devicelab/bin/tasks/ios_debug_workflow.dart index a5a62fb081ceb..371565662bf69 100644 --- a/dev/devicelab/bin/tasks/ios_debug_workflow.dart +++ b/dev/devicelab/bin/tasks/ios_debug_workflow.dart @@ -90,7 +90,7 @@ Future _validateWorkflow({ Pattern expectedLog; Pattern unexpectedLog; const Pattern xcodeExpectedLog = 'Action result status: not yet started'; - final Pattern lldbExpectedLog = RegExp(r'Process .* resuming'); + final Pattern lldbExpectedLog = RegExp(r'location added to breakpoint'); switch (workflow) { case IosDebugWorkflow.xcode: expectedLog = xcodeExpectedLog; diff --git a/dev/integration_tests/.gitignore b/dev/integration_tests/.gitignore new file mode 100644 index 0000000000000..b5b3ea9cc0f43 --- /dev/null +++ b/dev/integration_tests/.gitignore @@ -0,0 +1,4 @@ +# The generated web assets aren't needed for widget_preview_scaffold widget tests +# This is ignored here rather than in widget_preview_scaffold/ since the .gitignore +# in that directory is overwritten by the .gitignore template for the scaffold. +/widget_preview_scaffold/web/ \ No newline at end of file diff --git a/dev/integration_tests/android_verified_input/pubspec.yaml b/dev/integration_tests/android_verified_input/pubspec.yaml index 0f9f2a5b1665f..7cf4011daf4b1 100644 --- a/dev/integration_tests/android_verified_input/pubspec.yaml +++ b/dev/integration_tests/android_verified_input/pubspec.yaml @@ -22,7 +22,7 @@ dependencies: characters: 1.4.1 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade" collection: 1.19.1 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade" file: 7.0.1 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade" - matcher: 0.12.18 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade" + matcher: 0.12.19 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade" material_color_utilities: 0.13.0 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade" meta: ^1.17.0 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade" path: 1.9.1 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade" @@ -80,4 +80,4 @@ dev_dependencies: webkit_inspection_protocol: 1.2.1 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade" yaml: 3.1.3 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade" -# PUBSPEC CHECKSUM: fj70b8 +# PUBSPEC CHECKSUM: isrc4m diff --git a/dev/integration_tests/hook_user_defines/pubspec.yaml b/dev/integration_tests/hook_user_defines/pubspec.yaml index 3bf3fa023d4e7..3516f38bcbb96 100644 --- a/dev/integration_tests/hook_user_defines/pubspec.yaml +++ b/dev/integration_tests/hook_user_defines/pubspec.yaml @@ -20,6 +20,6 @@ dependencies: native_toolchain_c: 0.17.4 dev_dependencies: - test: 1.28.0 + test: 1.29.0 -# PUBSPEC CHECKSUM: qlfuuh +# PUBSPEC CHECKSUM: 76vsd6 diff --git a/dev/integration_tests/pure_android_host_apps/android_host_app_v2_embedding/settings.gradle b/dev/integration_tests/pure_android_host_apps/android_host_app_v2_embedding/settings.gradle index 5368feb7ecde7..66364cdf4a190 100644 --- a/dev/integration_tests/pure_android_host_apps/android_host_app_v2_embedding/settings.gradle +++ b/dev/integration_tests/pure_android_host_apps/android_host_app_v2_embedding/settings.gradle @@ -7,7 +7,7 @@ dependencyResolutionManagement { repositories { google() mavenCentral() - def flutterStorageUrl = System.getenv("FLUTTER_STORAGE_BASE_URL") ?: "https://storage.googleapis.com" + def flutterStorageUrl = System.getenv("FLUTTER_STORAGE_BASE_URL") ?: "https://download.shorebird.dev" maven { url = uri("$flutterStorageUrl/download.flutter.io") } diff --git a/dev/integration_tests/pure_android_host_apps/host_app_kotlin_gradle_dsl/settings.gradle.kts b/dev/integration_tests/pure_android_host_apps/host_app_kotlin_gradle_dsl/settings.gradle.kts index f6d75bce11757..da25b49a46f7f 100644 --- a/dev/integration_tests/pure_android_host_apps/host_app_kotlin_gradle_dsl/settings.gradle.kts +++ b/dev/integration_tests/pure_android_host_apps/host_app_kotlin_gradle_dsl/settings.gradle.kts @@ -16,7 +16,7 @@ dependencyResolutionManagement { repositories { google() mavenCentral() - val flutterStorageUrl = System.getenv("FLUTTER_STORAGE_BASE_URL") ?: "https://storage.googleapis.com" + val flutterStorageUrl = System.getenv("FLUTTER_STORAGE_BASE_URL") ?: "https://download.shorebird.dev" maven("$flutterStorageUrl/download.flutter.io") } } diff --git a/packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/README.md b/dev/integration_tests/widget_preview_scaffold/README.md similarity index 100% rename from packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/README.md rename to dev/integration_tests/widget_preview_scaffold/README.md diff --git a/packages/flutter_tools/test/widget_preview_scaffold.shard/README.md b/dev/integration_tests/widget_preview_scaffold/TESTING_README.md similarity index 79% rename from packages/flutter_tools/test/widget_preview_scaffold.shard/README.md rename to dev/integration_tests/widget_preview_scaffold/TESTING_README.md index 62cc5c96667f5..0a8775ae807fc 100644 --- a/packages/flutter_tools/test/widget_preview_scaffold.shard/README.md +++ b/dev/integration_tests/widget_preview_scaffold/TESTING_README.md @@ -4,4 +4,4 @@ This directory contains a hydrated instance of the `widget_preview_scaffold` pro # Updating the Hydrated Template -If any of the `widget_preview_scaffold` template files are updated, `widget_preview_scaffold/test/template_change_detection_smoke_test.dart` will fail to indicate that the hydrated scaffold needs to be regenerated. To do this, run `dart test/widget_preview_scaffold.shard/update_widget_preview_scaffold.dart` to regenerate the project. +If any of the `widget_preview_scaffold` template files are updated, `widget_preview_scaffold/test/template_change_detection_smoke_test.dart` will fail to indicate that the hydrated scaffold needs to be regenerated. To do this, run `dart update_widget_preview_scaffold.dart` to regenerate the project. diff --git a/packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/analysis_options.yaml b/dev/integration_tests/widget_preview_scaffold/analysis_options.yaml similarity index 100% rename from packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/analysis_options.yaml rename to dev/integration_tests/widget_preview_scaffold/analysis_options.yaml diff --git a/packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/lib/main.dart b/dev/integration_tests/widget_preview_scaffold/lib/main.dart similarity index 100% rename from packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/lib/main.dart rename to dev/integration_tests/widget_preview_scaffold/lib/main.dart diff --git a/packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/lib/src/controls.dart b/dev/integration_tests/widget_preview_scaffold/lib/src/controls.dart similarity index 100% rename from packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/lib/src/controls.dart rename to dev/integration_tests/widget_preview_scaffold/lib/src/controls.dart diff --git a/packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/lib/src/dtd/dtd_connection_info.dart b/dev/integration_tests/widget_preview_scaffold/lib/src/dtd/dtd_connection_info.dart similarity index 100% rename from packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/lib/src/dtd/dtd_connection_info.dart rename to dev/integration_tests/widget_preview_scaffold/lib/src/dtd/dtd_connection_info.dart diff --git a/packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/lib/src/dtd/dtd_services.dart b/dev/integration_tests/widget_preview_scaffold/lib/src/dtd/dtd_services.dart similarity index 100% rename from packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/lib/src/dtd/dtd_services.dart rename to dev/integration_tests/widget_preview_scaffold/lib/src/dtd/dtd_services.dart diff --git a/packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/lib/src/dtd/editor_service.dart b/dev/integration_tests/widget_preview_scaffold/lib/src/dtd/editor_service.dart similarity index 100% rename from packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/lib/src/dtd/editor_service.dart rename to dev/integration_tests/widget_preview_scaffold/lib/src/dtd/editor_service.dart diff --git a/packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/lib/src/dtd/utils.dart b/dev/integration_tests/widget_preview_scaffold/lib/src/dtd/utils.dart similarity index 100% rename from packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/lib/src/dtd/utils.dart rename to dev/integration_tests/widget_preview_scaffold/lib/src/dtd/utils.dart diff --git a/packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/lib/src/generated_preview.dart b/dev/integration_tests/widget_preview_scaffold/lib/src/generated_preview.dart similarity index 100% rename from packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/lib/src/generated_preview.dart rename to dev/integration_tests/widget_preview_scaffold/lib/src/generated_preview.dart diff --git a/packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/lib/src/split.dart b/dev/integration_tests/widget_preview_scaffold/lib/src/split.dart similarity index 100% rename from packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/lib/src/split.dart rename to dev/integration_tests/widget_preview_scaffold/lib/src/split.dart diff --git a/packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/lib/src/theme/_ide_theme_desktop.dart b/dev/integration_tests/widget_preview_scaffold/lib/src/theme/_ide_theme_desktop.dart similarity index 100% rename from packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/lib/src/theme/_ide_theme_desktop.dart rename to dev/integration_tests/widget_preview_scaffold/lib/src/theme/_ide_theme_desktop.dart diff --git a/packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/lib/src/theme/_ide_theme_web.dart b/dev/integration_tests/widget_preview_scaffold/lib/src/theme/_ide_theme_web.dart similarity index 100% rename from packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/lib/src/theme/_ide_theme_web.dart rename to dev/integration_tests/widget_preview_scaffold/lib/src/theme/_ide_theme_web.dart diff --git a/packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/lib/src/theme/ide_theme.dart b/dev/integration_tests/widget_preview_scaffold/lib/src/theme/ide_theme.dart similarity index 100% rename from packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/lib/src/theme/ide_theme.dart rename to dev/integration_tests/widget_preview_scaffold/lib/src/theme/ide_theme.dart diff --git a/packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/lib/src/theme/theme.dart b/dev/integration_tests/widget_preview_scaffold/lib/src/theme/theme.dart similarity index 100% rename from packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/lib/src/theme/theme.dart rename to dev/integration_tests/widget_preview_scaffold/lib/src/theme/theme.dart diff --git a/packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/lib/src/utils.dart b/dev/integration_tests/widget_preview_scaffold/lib/src/utils.dart similarity index 100% rename from packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/lib/src/utils.dart rename to dev/integration_tests/widget_preview_scaffold/lib/src/utils.dart diff --git a/packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/lib/src/utils/color_utils.dart b/dev/integration_tests/widget_preview_scaffold/lib/src/utils/color_utils.dart similarity index 100% rename from packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/lib/src/utils/color_utils.dart rename to dev/integration_tests/widget_preview_scaffold/lib/src/utils/color_utils.dart diff --git a/packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/lib/src/utils/url/_url_stub.dart b/dev/integration_tests/widget_preview_scaffold/lib/src/utils/url/_url_stub.dart similarity index 100% rename from packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/lib/src/utils/url/_url_stub.dart rename to dev/integration_tests/widget_preview_scaffold/lib/src/utils/url/_url_stub.dart diff --git a/packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/lib/src/utils/url/_url_web.dart b/dev/integration_tests/widget_preview_scaffold/lib/src/utils/url/_url_web.dart similarity index 100% rename from packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/lib/src/utils/url/_url_web.dart rename to dev/integration_tests/widget_preview_scaffold/lib/src/utils/url/_url_web.dart diff --git a/packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/lib/src/utils/url/url.dart b/dev/integration_tests/widget_preview_scaffold/lib/src/utils/url/url.dart similarity index 100% rename from packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/lib/src/utils/url/url.dart rename to dev/integration_tests/widget_preview_scaffold/lib/src/utils/url/url.dart diff --git a/packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/lib/src/widget_preview.dart b/dev/integration_tests/widget_preview_scaffold/lib/src/widget_preview.dart similarity index 100% rename from packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/lib/src/widget_preview.dart rename to dev/integration_tests/widget_preview_scaffold/lib/src/widget_preview.dart diff --git a/packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/lib/src/widget_preview_inspector_service.dart b/dev/integration_tests/widget_preview_scaffold/lib/src/widget_preview_inspector_service.dart similarity index 100% rename from packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/lib/src/widget_preview_inspector_service.dart rename to dev/integration_tests/widget_preview_scaffold/lib/src/widget_preview_inspector_service.dart diff --git a/packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/lib/src/widget_preview_rendering.dart b/dev/integration_tests/widget_preview_scaffold/lib/src/widget_preview_rendering.dart similarity index 100% rename from packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/lib/src/widget_preview_rendering.dart rename to dev/integration_tests/widget_preview_scaffold/lib/src/widget_preview_rendering.dart diff --git a/packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/lib/src/widget_preview_scaffold_controller.dart b/dev/integration_tests/widget_preview_scaffold/lib/src/widget_preview_scaffold_controller.dart similarity index 100% rename from packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/lib/src/widget_preview_scaffold_controller.dart rename to dev/integration_tests/widget_preview_scaffold/lib/src/widget_preview_scaffold_controller.dart diff --git a/packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/pubspec.yaml b/dev/integration_tests/widget_preview_scaffold/pubspec.yaml similarity index 92% rename from packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/pubspec.yaml rename to dev/integration_tests/widget_preview_scaffold/pubspec.yaml index 6e0dbc117dd60..b601f07a7bdc7 100644 --- a/packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/pubspec.yaml +++ b/dev/integration_tests/widget_preview_scaffold/pubspec.yaml @@ -35,7 +35,7 @@ dependencies: leak_tracker_flutter_testing: 3.0.10 leak_tracker_testing: 3.0.2 lints: 6.0.0 - matcher: 0.12.18 + matcher: 0.12.19 material_color_utilities: 0.13.0 meta: 1.17.0 plugin_platform_interface: 2.1.8 @@ -43,7 +43,7 @@ dependencies: stream_channel: 2.1.4 string_scanner: 1.4.1 term_glyph: 1.2.2 - test_api: 0.7.8 + test_api: 0.7.10 typed_data: 1.4.0 unified_analytics: 8.0.10 url_launcher_android: 6.3.28 @@ -63,9 +63,9 @@ dependencies: dev_dependencies: flutter_tools: - path: ../../../ - test: 1.28.0 + path: ../../../packages/flutter_tools/ + test: 1.30.0 flutter: uses-material-design: true -# PUBSPEC CHECKSUM: bl1ls3 +# PUBSPEC CHECKSUM: m9voc4 diff --git a/packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/test/custom_previews_test.dart b/dev/integration_tests/widget_preview_scaffold/test/custom_previews_test.dart similarity index 100% rename from packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/test/custom_previews_test.dart rename to dev/integration_tests/widget_preview_scaffold/test/custom_previews_test.dart diff --git a/packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/test/dtd_services_test.dart b/dev/integration_tests/widget_preview_scaffold/test/dtd_services_test.dart similarity index 95% rename from packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/test/dtd_services_test.dart rename to dev/integration_tests/widget_preview_scaffold/test/dtd_services_test.dart index 7f9c3ca907e88..3c65f7910d52c 100644 --- a/packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/test/dtd_services_test.dart +++ b/dev/integration_tests/widget_preview_scaffold/test/dtd_services_test.dart @@ -17,10 +17,10 @@ import 'package:flutter_tools/src/widget_preview/persistent_preferences.dart'; import 'package:test/fake.dart'; import 'package:widget_preview_scaffold/src/dtd/dtd_services.dart'; -import '../../../src/common.dart'; -import '../../../src/context.dart'; -import '../../../src/fakes.dart'; -import '../../../commands.shard/permeable/utils/project_testing_utils.dart'; +import '../../../../packages/flutter_tools/test/src/common.dart'; +import '../../../../packages/flutter_tools/test/src/context.dart'; +import '../../../../packages/flutter_tools/test/src/fakes.dart'; +import '../../../../packages/flutter_tools/test/commands.shard/permeable/utils/project_testing_utils.dart'; class FakeFlutterProject extends Fake implements FlutterProject { FakeFlutterProject(); diff --git a/packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/test/error_widget_test.dart b/dev/integration_tests/widget_preview_scaffold/test/error_widget_test.dart similarity index 100% rename from packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/test/error_widget_test.dart rename to dev/integration_tests/widget_preview_scaffold/test/error_widget_test.dart diff --git a/packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/test/filter_by_selected_file_test.dart b/dev/integration_tests/widget_preview_scaffold/test/filter_by_selected_file_test.dart similarity index 100% rename from packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/test/filter_by_selected_file_test.dart rename to dev/integration_tests/widget_preview_scaffold/test/filter_by_selected_file_test.dart diff --git a/packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/test/hot_restart_test.dart b/dev/integration_tests/widget_preview_scaffold/test/hot_restart_test.dart similarity index 100% rename from packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/test/hot_restart_test.dart rename to dev/integration_tests/widget_preview_scaffold/test/hot_restart_test.dart diff --git a/packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/test/ide_theme_test.dart b/dev/integration_tests/widget_preview_scaffold/test/ide_theme_test.dart similarity index 100% rename from packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/test/ide_theme_test.dart rename to dev/integration_tests/widget_preview_scaffold/test/ide_theme_test.dart diff --git a/packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/test/localizations_test.dart b/dev/integration_tests/widget_preview_scaffold/test/localizations_test.dart similarity index 100% rename from packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/test/localizations_test.dart rename to dev/integration_tests/widget_preview_scaffold/test/localizations_test.dart diff --git a/packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/test/no_previews_detected_test.dart b/dev/integration_tests/widget_preview_scaffold/test/no_previews_detected_test.dart similarity index 100% rename from packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/test/no_previews_detected_test.dart rename to dev/integration_tests/widget_preview_scaffold/test/no_previews_detected_test.dart diff --git a/packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/test/soft_restart_test.dart b/dev/integration_tests/widget_preview_scaffold/test/soft_restart_test.dart similarity index 100% rename from packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/test/soft_restart_test.dart rename to dev/integration_tests/widget_preview_scaffold/test/soft_restart_test.dart diff --git a/dev/integration_tests/widget_preview_scaffold/test/template_change_detection_smoke_test.dart b/dev/integration_tests/widget_preview_scaffold/test/template_change_detection_smoke_test.dart new file mode 100644 index 0000000000000..6cfed47edabc0 --- /dev/null +++ b/dev/integration_tests/widget_preview_scaffold/test/template_change_detection_smoke_test.dart @@ -0,0 +1,43 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as path; + +import 'widget_preview_scaffold_change_detector.dart'; + +void main() { + test('Widget Preview Scaffold template change detection', () { + expect( + path.basename(Directory.current.path), + 'widget_preview_scaffold', + reason: + 'This test must be run from dev/integration_tests/widget_preview_scaffold/', + ); + if (WidgetPreviewScaffoldChangeDetector.checkForTemplateUpdates( + widgetPreviewScaffoldProject: Directory.current, + widgetPreviewScaffoldTemplateDir: Directory( + path.join( + '..', + '..', + '..', + 'packages', + 'flutter_tools', + 'templates', + 'widget_preview_scaffold', + ), + ), + )) { + stdout.writeln( + 'The widget_preview_scaffold contents do not match the widget_preview_scaffold ' + 'templates. Run "dart dev/integration_tests/widget_preview_scaffold/' + 'update_widget_preview_scaffold.dart" to update widget_preview_scaffold with the latest ' + 'template contents.', + ); + fail('widget_preview_scaffold is not up to date.'); + } + }); +} diff --git a/packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/test/theme_test.dart b/dev/integration_tests/widget_preview_scaffold/test/theme_test.dart similarity index 100% rename from packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/test/theme_test.dart rename to dev/integration_tests/widget_preview_scaffold/test/theme_test.dart diff --git a/packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/test/theming_test.dart b/dev/integration_tests/widget_preview_scaffold/test/theming_test.dart similarity index 100% rename from packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/test/theming_test.dart rename to dev/integration_tests/widget_preview_scaffold/test/theming_test.dart diff --git a/packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/test/utils/localizations_utils.dart b/dev/integration_tests/widget_preview_scaffold/test/utils/localizations_utils.dart similarity index 100% rename from packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/test/utils/localizations_utils.dart rename to dev/integration_tests/widget_preview_scaffold/test/utils/localizations_utils.dart diff --git a/packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/test/utils/widget_preview_scaffold_test_utils.dart b/dev/integration_tests/widget_preview_scaffold/test/utils/widget_preview_scaffold_test_utils.dart similarity index 100% rename from packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/test/utils/widget_preview_scaffold_test_utils.dart rename to dev/integration_tests/widget_preview_scaffold/test/utils/widget_preview_scaffold_test_utils.dart diff --git a/packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/test/widget_inspector_service_override_test.dart b/dev/integration_tests/widget_preview_scaffold/test/widget_inspector_service_override_test.dart similarity index 100% rename from packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/test/widget_inspector_service_override_test.dart rename to dev/integration_tests/widget_preview_scaffold/test/widget_inspector_service_override_test.dart diff --git a/packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/test/widget_inspector_test.dart b/dev/integration_tests/widget_preview_scaffold/test/widget_inspector_test.dart similarity index 100% rename from packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/test/widget_inspector_test.dart rename to dev/integration_tests/widget_preview_scaffold/test/widget_inspector_test.dart diff --git a/packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/test/widget_preview_scaffold_change_detector.dart b/dev/integration_tests/widget_preview_scaffold/test/widget_preview_scaffold_change_detector.dart similarity index 93% rename from packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/test/widget_preview_scaffold_change_detector.dart rename to dev/integration_tests/widget_preview_scaffold/test/widget_preview_scaffold_change_detector.dart index 9509f5a9d8509..47116d1f1f2d2 100644 --- a/packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/test/widget_preview_scaffold_change_detector.dart +++ b/dev/integration_tests/widget_preview_scaffold/test/widget_preview_scaffold_change_detector.dart @@ -4,6 +4,8 @@ import 'dart:io'; +import 'package:path/path.dart' as path; + abstract class WidgetPreviewScaffoldChangeDetector { static final Set _ignoreDiffSet = { // The pubspec can't be compared directly to the template since the SDK version is populated @@ -30,8 +32,10 @@ abstract class WidgetPreviewScaffoldChangeDetector { if (_ignoreDiffSet.contains(scaffoldPath)) { continue; } - final String resolvedScaffoldPath = - '${widgetPreviewScaffoldProject.absolute.path}$scaffoldPath'; + final String resolvedScaffoldPath = path.join( + widgetPreviewScaffoldProject.absolute.path, + scaffoldPath, + ); if (entity is Directory) { if (!Directory(resolvedScaffoldPath).existsSync()) { stdout.writeln( diff --git a/packages/flutter_tools/test/widget_preview_scaffold.shard/update_widget_preview_scaffold.dart b/dev/integration_tests/widget_preview_scaffold/update_widget_preview_scaffold.dart similarity index 50% rename from packages/flutter_tools/test/widget_preview_scaffold.shard/update_widget_preview_scaffold.dart rename to dev/integration_tests/widget_preview_scaffold/update_widget_preview_scaffold.dart index bf7b6c919c926..0b384ce332ec8 100644 --- a/packages/flutter_tools/test/widget_preview_scaffold.shard/update_widget_preview_scaffold.dart +++ b/dev/integration_tests/widget_preview_scaffold/update_widget_preview_scaffold.dart @@ -4,26 +4,38 @@ import 'dart:io'; -import 'package:path/path.dart' as path; // flutter_ignore: package_path_import +import 'package:path/path.dart' as path; -import 'widget_preview_scaffold/test/widget_preview_scaffold_change_detector.dart'; +import 'test/widget_preview_scaffold_change_detector.dart'; /// Regenerates the widget_preview_scaffold if needed. void main() { if (WidgetPreviewScaffoldChangeDetector.checkForTemplateUpdates( - widgetPreviewScaffoldProject: Directory( - Platform.script.resolve('widget_preview_scaffold/').path, - ), + widgetPreviewScaffoldProject: Directory(Platform.script.resolve('.').path), widgetPreviewScaffoldTemplateDir: Directory( - Platform.script.resolve(path.join('..', '..', 'templates', 'widget_preview_scaffold')).path, + Platform.script + .resolve( + path.join( + '..', + '..', + '..', + 'packages', + 'flutter_tools', + 'templates', + 'widget_preview_scaffold', + ), + ) + .path, ), )) { - stdout.writeln('Changes detected in the widget_preview_scaffold project templates.'); + stdout.writeln( + 'Changes detected in the widget_preview_scaffold project templates.', + ); stdout.writeln('Regenerating...'); final args = [ 'widget-preview', 'start', - '--scaffold-output-dir=${Platform.script.resolve('widget_preview_scaffold').path}', + '--scaffold-output-dir=${Platform.script.resolve('.').path}', ]; stdout.writeln('Executing: flutter ${args.join(' ')}'); final ProcessResult result = Process.runSync('flutter', args); @@ -31,6 +43,8 @@ void main() { stderr.writeln(result.stderr); stdout.writeln('Regenerated widget_preview_scaffold.'); } else { - stdout.writeln('No changes detected in the widget_preview_scaffold project templates.'); + stdout.writeln( + 'No changes detected in the widget_preview_scaffold project templates.', + ); } } diff --git a/dev/tools/create_api_docs.dart b/dev/tools/create_api_docs.dart index a98f16398b21f..4ece883e09df7 100644 --- a/dev/tools/create_api_docs.dart +++ b/dev/tools/create_api_docs.dart @@ -923,8 +923,8 @@ class PlatformDocGenerator { for (final String platform in kPlatformDocs.keys) { final String zipFile = kPlatformDocs[platform]!.zipName; - final url = - 'https://storage.googleapis.com/${realm}flutter_infra_release/flutter/$engineRevision/$zipFile'; + final String url = + 'https://download.shorebird.dev/${realm}flutter_infra_release/flutter/$engineRevision/$zipFile'; await _extractDocs(url, platform, kPlatformDocs[platform]!, outputDir); } } diff --git a/engine/src/build/config/compiler/BUILD.gn b/engine/src/build/config/compiler/BUILD.gn index 2d5e0e0ccbcef..7df82c918b172 100644 --- a/engine/src/build/config/compiler/BUILD.gn +++ b/engine/src/build/config/compiler/BUILD.gn @@ -443,7 +443,10 @@ config("compiler") { # Example PR: https://github.com/dart-lang/native/pull/1615 ldflags += [ "-Wl,--no-undefined", - "-Wl,--exclude-libs,ALL", + + # TODO: Terrible hack, but otherwise libupdater.a symbols can't + # be exported from libflutter.so, even when added to android_exports.lst. + # "-Wl,--exclude-libs,ALL", # Enable identical code folding to reduce size. "-Wl,--icf=all", @@ -646,6 +649,8 @@ config("runtime_library") { ldflags += [ "-Wl,--warn-shared-textrel" ] libs += [ + # Rust requires libunwind. + "unwind", "c", "dl", "m", @@ -660,6 +665,9 @@ config("runtime_library") { } else if (current_cpu == "x86") { current_android_cpu = "i686" } + # libunwind.a is located in the respective android cpu subdirectories. + # The clang version needs to match the version in the lib_dirs line above. + lib_dirs += [ "${android_toolchain_root}/lib/clang/19/lib/linux/${current_android_cpu}/" ] libs += [ "clang_rt.builtins-${current_android_cpu}-android" ] } diff --git a/engine/src/build/toolchain/win/BUILD.gn b/engine/src/build/toolchain/win/BUILD.gn index 45a98b1ecd64b..b5073fb22277b 100644 --- a/engine/src/build/toolchain/win/BUILD.gn +++ b/engine/src/build/toolchain/win/BUILD.gn @@ -204,8 +204,11 @@ template("msvc_toolchain") { expname = "${dllname}.exp" pdbname = "${dllname}.pdb" rspfile = "${dllname}.rsp" + # .def files are used to export symbols from the DLL. This arg will be + # removed by the python tool wrapper if the .def file doesn't exist. + deffile = "${dllname}.def" - link_command = "\"$python_path\" $tool_wrapper_path link-wrapper $env False link.exe /nologo /IMPLIB:$libname /DLL /OUT:$dllname /PDB:${dllname}.pdb @$rspfile" + link_command = "\"$python_path\" $tool_wrapper_path link-wrapper $env False link.exe /nologo /IMPLIB:$libname /DLL /OUT:$dllname /PDB:${dllname}.pdb /DEF:$deffile @$rspfile" # TODO(brettw) support manifests #manifest_command = "\"$python_path\" $tool_wrapper_path manifest-wrapper $env mt.exe -nologo -manifest $manifests -out:${dllname}.manifest" diff --git a/engine/src/build/toolchain/win/tool_wrapper.py b/engine/src/build/toolchain/win/tool_wrapper.py index b4fc8485ffa17..7866c6122d832 100644 --- a/engine/src/build/toolchain/win/tool_wrapper.py +++ b/engine/src/build/toolchain/win/tool_wrapper.py @@ -121,6 +121,17 @@ def ExecLinkWrapper(self, arch, use_separate_mspdbsrv, *args): self._UseSeparateMspdbsrv(env, args) if sys.platform == 'win32': args = list(args) # *args is a tuple by default, which is read-only. + + # Remove the /DEF arg if not provided. We would ideally be able to do this + # in build\toolchain\win\BUILD.gn, but there doesn't seem to be a way to + # conditionally add args to the command line based on whether a file exists + # or not, so we do it here instead. + def_arg_prefix = "/DEF:" + for arg in args: + if arg.startswith(def_arg_prefix): + def_file = arg[len(def_arg_prefix):] + if not os.path.exists(def_file): + args.remove(arg) args[0] = args[0].replace('/', '\\') # https://docs.python.org/2/library/subprocess.html: # "On Unix with shell=True [...] if args is a sequence, the first item diff --git a/engine/src/flutter/BUILD.gn b/engine/src/flutter/BUILD.gn index ec7b55d24f152..348f732c329ed 100644 --- a/engine/src/flutter/BUILD.gn +++ b/engine/src/flutter/BUILD.gn @@ -118,6 +118,9 @@ group("flutter") { # path_ops "//flutter/tools/path_ops", + + # Built alongside gen_snapshot arm64 targets. + "$dart_src/runtime/bin:analyze_snapshot", ] if (host_os == "linux" || host_os == "mac") { @@ -127,13 +130,6 @@ group("flutter") { ] } - if (host_os == "linux") { - public_deps += [ - # Built alongside gen_snapshot for 64 bit targets - "$dart_src/runtime/bin:analyze_snapshot", - ] - } - if (full_dart_sdk) { public_deps += [ "//flutter/web_sdk" ] } @@ -213,6 +209,7 @@ group("unittests") { "//flutter/runtime:no_dart_plugin_registrant_unittests", "//flutter/runtime:runtime_unittests", "//flutter/shell/common:shell_unittests", + "//flutter/shell/common/shorebird:shorebird_unittests", "//flutter/shell/geometry:geometry_unittests", "//flutter/shell/platform/embedder:embedder_a11y_unittests", "//flutter/shell/platform/embedder:embedder_proctable_unittests", @@ -344,3 +341,19 @@ if (host_os == "win") { outputs = [ "$root_build_dir/gen_snapshot/gen_snapshot.exe" ] } } + +# A top-level target for analyze_snapshot, modeled after the gen_snapshot +# target above. +if (host_os == "win") { + _analyze_snapshot_target = + "$dart_src/runtime/bin:analyze_snapshot($host_toolchain)" + + copy("analyze_snapshot") { + deps = [ _analyze_snapshot_target ] + + analyze_snapshot_out_dir = + get_label_info(_analyze_snapshot_target, "root_out_dir") + sources = [ "$analyze_snapshot_out_dir/analyze_snapshot.exe" ] + outputs = [ "$root_build_dir/analyze_snapshot/analyze_snapshot.exe" ] + } +} diff --git a/engine/src/flutter/build/archives/BUILD.gn b/engine/src/flutter/build/archives/BUILD.gn index 4f902010d21dc..55700433541db 100644 --- a/engine/src/flutter/build/archives/BUILD.gn +++ b/engine/src/flutter/build/archives/BUILD.gn @@ -45,6 +45,7 @@ generated_file("artifacts_entitlement_config") { if (build_engine_artifacts) { zip_bundle("artifacts") { deps = [ + "$dart_src/runtime/bin:analyze_snapshot", "$dart_src/runtime/bin:gen_snapshot", "//flutter/flutter_frontend_server:frontend_server", "//flutter/impeller/compiler:impellerc", @@ -142,6 +143,10 @@ if (build_engine_artifacts) { if (host_os == "mac") { deps += [ ":artifacts_entitlement_config" ] files += [ + { + source = "$root_out_dir/analyze_snapshot$exe" + destination = "analyze_snapshot$exe" + }, { source = "$target_gen_dir/entitlements.txt" destination = "entitlements.txt" @@ -320,14 +325,25 @@ if (is_mac) { } if (host_os == "win") { + # This rule archives both gen_snapshot *and* analyze_snapshot. The name is + # misleading. We (shorebird) have updated this rule to include + # analyze_snapshot but did not update the name because it is referenced + # elsewhere in the tooling. zip_bundle("archive_win_gen_snapshot") { - deps = [ "//flutter:gen_snapshot" ] + deps = [ + "//flutter:analyze_snapshot", + "//flutter:gen_snapshot", + ] output = "$full_target_platform_name-$flutter_runtime_mode/windows-x64.zip" files = [ { source = "$root_out_dir/gen_snapshot/gen_snapshot.exe" destination = "gen_snapshot.exe" }, + { + source = "$root_out_dir/analyze_snapshot/analyze_snapshot.exe" + destination = "analyze_snapshot.exe" + }, ] } } diff --git a/engine/src/flutter/build/dart/tools/dart_pkg.py b/engine/src/flutter/build/dart/tools/dart_pkg.py index c60e3e02431e4..5b3e0a8b5f3ee 100755 --- a/engine/src/flutter/build/dart/tools/dart_pkg.py +++ b/engine/src/flutter/build/dart/tools/dart_pkg.py @@ -163,7 +163,10 @@ def main(): for source in args.package_sources: relative_source = os.path.relpath(source, common_source_prefix) target = os.path.join(target_dir, relative_source) - copy(source, target) + try: + copy(source, target) + except shutil.SameFileError: + pass # Copy sdk-ext sources into pkg directory sdk_ext_dir = os.path.join(target_dir, 'sdk_ext') @@ -179,7 +182,10 @@ def main(): for source in args.sdk_ext_files: relative_source = os.path.relpath(source, common_source_prefix) target = os.path.join(sdk_ext_dir, relative_source) - copy(source, target) + try: + copy(source, target) + except shutil.SameFileError: + pass # Write stamp file. with open(args.stamp_file, 'w'): diff --git a/engine/src/flutter/build/zip_bundle.gni b/engine/src/flutter/build/zip_bundle.gni index 51e72df0ad854..707ab046d3637 100644 --- a/engine/src/flutter/build/zip_bundle.gni +++ b/engine/src/flutter/build/zip_bundle.gni @@ -55,7 +55,7 @@ template("zip_bundle") { license_path = rebase_path("//flutter/sky/packages/sky_engine/LICENSE", "//flutter") git_url = "https://github.com/flutter/engine/tree/$engine_version" - sky_engine_url = "https://storage.googleapis.com/flutter_infra_release/flutter/$engine_version/sky_engine.zip" + sky_engine_url = "https://download.shorebird.dev/flutter_infra_release/flutter/$engine_version/sky_engine.zip" outputs = [ license_readme ] contents = [ "# $target_name", diff --git a/engine/src/flutter/ci/builders/linux_web_engine_test.json b/engine/src/flutter/ci/builders/linux_web_engine_test.json index 04b18623f2381..55a59bd31fffb 100644 --- a/engine/src/flutter/ci/builders/linux_web_engine_test.json +++ b/engine/src/flutter/ci/builders/linux_web_engine_test.json @@ -24,6 +24,28 @@ ] } }, + { + "name": "web_tests/test_bundles/dart2wasm-skwasm-skwasm", + "drone_dimensions": [ + "device_type=none", + "os=Linux" + ], + "generators": { + "tasks": [ + { + "name": "compile bundle dart2wasm-skwasm-skwasm", + "parameters": [ + "test", + "--compile", + "--bundle=dart2wasm-skwasm-skwasm" + ], + "scripts": [ + "flutter/lib/web_ui/dev/felt" + ] + } + ] + } + }, { "name": "web_tests/test_bundles/dart2js-canvaskit-engine", "drone_dimensions": [ @@ -166,6 +188,7 @@ }, "dependencies": [ "web_tests/test_bundles/dart2wasm-skwasm-ui", + "web_tests/test_bundles/dart2wasm-skwasm-skwasm", "web_tests/test_bundles/dart2js-canvaskit-engine", "web_tests/test_bundles/dart2js-canvaskit-canvaskit", "web_tests/test_bundles/dart2js-canvaskit-ui", @@ -189,6 +212,7 @@ "test", "--copy-artifacts", "--suite=chrome-dart2wasm-wimp-ui", + "--suite=chrome-dart2wasm-wimp-skwasm", "--suite=chrome-dart2js-canvaskit-engine", "--suite=chrome-dart2js-canvaskit-canvaskit", "--suite=chrome-dart2js-canvaskit-ui", @@ -196,7 +220,9 @@ "--suite=chrome-full-dart2js-canvaskit-ui", "--suite=chrome-dart2wasm-canvaskit-engine", "--suite=chrome-coi-dart2wasm-skwasm-ui", + "--suite=chrome-coi-dart2wasm-skwasm-skwasm", "--suite=chrome-force-st-dart2wasm-skwasm-ui", + "--suite=chrome-force-st-dart2wasm-skwasm-skwasm", "--suite=chrome-fallbacks", "--suite=chrome-coi-fallbacks", "--suite=chrome-force-st-fallbacks" @@ -212,6 +238,15 @@ ], "script": "flutter/lib/web_ui/dev/felt" }, + { + "name": "run suite chrome-dart2wasm-wimp-skwasm", + "parameters": [ + "test", + "--run", + "--suite=chrome-dart2wasm-wimp-skwasm" + ], + "script": "flutter/lib/web_ui/dev/felt" + }, { "name": "run suite chrome-dart2js-canvaskit-engine", "parameters": [ @@ -275,6 +310,15 @@ ], "script": "flutter/lib/web_ui/dev/felt" }, + { + "name": "run suite chrome-coi-dart2wasm-skwasm-skwasm", + "parameters": [ + "test", + "--run", + "--suite=chrome-coi-dart2wasm-skwasm-skwasm" + ], + "script": "flutter/lib/web_ui/dev/felt" + }, { "name": "run suite chrome-force-st-dart2wasm-skwasm-ui", "parameters": [ @@ -284,6 +328,15 @@ ], "script": "flutter/lib/web_ui/dev/felt" }, + { + "name": "run suite chrome-force-st-dart2wasm-skwasm-skwasm", + "parameters": [ + "test", + "--run", + "--suite=chrome-force-st-dart2wasm-skwasm-skwasm" + ], + "script": "flutter/lib/web_ui/dev/felt" + }, { "name": "run suite chrome-fallbacks", "parameters": [ diff --git a/engine/src/flutter/common/config.gni b/engine/src/flutter/common/config.gni index 318d305bd37fc..35ec1c7e058dc 100644 --- a/engine/src/flutter/common/config.gni +++ b/engine/src/flutter/common/config.gni @@ -67,6 +67,14 @@ if (slimpeller) { feature_defines_list += [ "SLIMPELLER=1" ] } +if (is_android || is_ios || is_linux || is_mac || is_win) { + feature_defines_list += [ "SHOREBIRD_PLATFORM_SUPPORTED=1" ] +} + +if (is_ios) { + feature_defines_list += [ "SHOREBIRD_USE_INTERPRETER=1" ] +} + if (is_ios || is_mac) { flutter_cflags_objc = [ "-Werror=overriding-method-mismatch", diff --git a/engine/src/flutter/fml/BUILD.gn b/engine/src/flutter/fml/BUILD.gn index 479d697f0d0d6..975b5296e1e3f 100644 --- a/engine/src/flutter/fml/BUILD.gn +++ b/engine/src/flutter/fml/BUILD.gn @@ -67,6 +67,8 @@ source_set("fml") { "process.h", "raster_thread_merger.cc", "raster_thread_merger.h", + "safe_math.cc", + "safe_math.h", "shared_thread_merger.cc", "shared_thread_merger.h", "status.h", @@ -111,7 +113,7 @@ source_set("fml") { ":string_conversion", ] - deps = [] + deps = [ "//third_party/abseil-cpp/absl/numeric:int128" ] if (target_os != "wasm") { deps += [ "//flutter/third_party/icu" ] @@ -350,6 +352,7 @@ if (enable_unittests) { "message_loop_unittests.cc", "paths_unittests.cc", "raster_thread_merger_unittests.cc", + "safe_math_unittests.cc", "string_conversion_unittests.cc", "synchronization/count_down_latch_unittests.cc", "synchronization/semaphore_unittest.cc", diff --git a/engine/src/flutter/fml/safe_math.cc b/engine/src/flutter/fml/safe_math.cc new file mode 100644 index 0000000000000..f68f3158a3243 --- /dev/null +++ b/engine/src/flutter/fml/safe_math.cc @@ -0,0 +1,41 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include + +#include "flutter/fml/safe_math.h" +#include "third_party/abseil-cpp/absl/numeric/int128.h" + +namespace fml { + +size_t SafeMath::mul(size_t x, size_t y) { + return sizeof(size_t) == sizeof(uint64_t) ? mul64(x, y) : mul32(x, y); +} + +uint32_t SafeMath::mul32(uint32_t x, uint32_t y) { + uint64_t big_x = x; + uint64_t big_y = y; + uint64_t result = big_x * big_y; + if (result >> 32) { + overflow_detected_ = true; + } + return static_cast(result); +} + +uint64_t SafeMath::mul64(uint64_t x, uint64_t y) { + if (x <= std::numeric_limits::max() && + y <= std::numeric_limits::max()) { + return x * y; + } + + absl::uint128 big_x = x; + absl::uint128 big_y = y; + absl::uint128 result = big_x * big_y; + if (absl::Uint128High64(result)) { + overflow_detected_ = true; + } + return absl::Uint128Low64(result); +} + +} // namespace fml diff --git a/engine/src/flutter/fml/safe_math.h b/engine/src/flutter/fml/safe_math.h new file mode 100644 index 0000000000000..14d424647afa9 --- /dev/null +++ b/engine/src/flutter/fml/safe_math.h @@ -0,0 +1,30 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef FLUTTER_FML_SAFE_MATH_H_ +#define FLUTTER_FML_SAFE_MATH_H_ + +#include +#include + +namespace fml { + +// Math operations that check for overflow. +// Based on Skia's SkSafeMath. +class SafeMath { + public: + bool overflow_detected() const { return overflow_detected_; } + + size_t mul(size_t x, size_t y); + + private: + uint32_t mul32(uint32_t x, uint32_t y); + uint64_t mul64(uint64_t x, uint64_t y); + + bool overflow_detected_ = false; +}; + +} // namespace fml + +#endif // FLUTTER_FML_SAFE_MATH_H_ diff --git a/engine/src/flutter/fml/safe_math_unittests.cc b/engine/src/flutter/fml/safe_math_unittests.cc new file mode 100644 index 0000000000000..c272cc564f605 --- /dev/null +++ b/engine/src/flutter/fml/safe_math_unittests.cc @@ -0,0 +1,39 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include + +#include "flutter/fml/safe_math.h" +#include "gtest/gtest.h" + +namespace flutter { +namespace testing { + +TEST(SafeMathTest, MultiplySizeT) { + // Multiplication with no overflow. + fml::SafeMath safe1; + EXPECT_EQ(safe1.mul(1000, 2000), static_cast(2000000)); + EXPECT_FALSE(safe1.overflow_detected()); + + // Overflow detection when multiplying size_t values at or near the maximum. + fml::SafeMath safe2; + safe2.mul(std::numeric_limits::max(), + std::numeric_limits::max()); + EXPECT_TRUE(safe2.overflow_detected()); + + fml::SafeMath safe3; + safe3.mul(std::numeric_limits::max() >> 2, 5); + EXPECT_TRUE(safe3.overflow_detected()); + + // Overflow detection for a result that slightly exceeds the range of a + // uint64_t. + if (sizeof(size_t) == sizeof(uint64_t)) { + fml::SafeMath safe4; + safe4.mul(static_cast(1ULL << 32), static_cast(1ULL << 32)); + EXPECT_TRUE(safe4.overflow_detected()); + } +} + +} // namespace testing +} // namespace flutter diff --git a/engine/src/flutter/impeller/display_list/aiks_dl_basic_unittests.cc b/engine/src/flutter/impeller/display_list/aiks_dl_basic_unittests.cc index 004d47761b937..cddc286349b56 100644 --- a/engine/src/flutter/impeller/display_list/aiks_dl_basic_unittests.cc +++ b/engine/src/flutter/impeller/display_list/aiks_dl_basic_unittests.cc @@ -20,6 +20,7 @@ #include "flutter/impeller/geometry/scalar.h" #include "flutter/testing/display_list_testing.h" #include "flutter/testing/testing.h" +#include "imgui.h" #include "impeller/playground/widgets.h" namespace impeller { @@ -686,6 +687,47 @@ TEST_P(AiksTest, FilledCirclesRenderCorrectly) { ASSERT_TRUE(OpenPlaygroundHere(builder.Build())); } +TEST_P(AiksTest, DrawThinStrokedCircle) { + auto callback = [&]() { + static float stroked_radius = 100.0; + static float stroke_width = 0.0; + static float stroke_width_fine = 2.0; + static float stroked_alpha = 255.0; + static float stroked_scale[2] = {1.0, 1.0}; + + if (AiksTest::ImGuiBegin("Controls", nullptr, + ImGuiWindowFlags_AlwaysAutoResize)) { + ImGui::SliderFloat("Stroked Radius", &stroked_radius, 0, 500); + ImGui::SliderFloat("Stroked Width", &stroke_width, 0, 500); + ImGui::SliderFloat("Stroked Width Fine", &stroke_width_fine, 0, 5); + ImGui::SliderFloat("Stroked Alpha", &stroked_alpha, 0, 10.0); + ImGui::SliderFloat2("Stroked Scale", stroked_scale, 0, 10.0); + ImGui::End(); + } + + flutter::DisplayListBuilder builder; + + DlPaint background_paint; + background_paint.setColor(DlColor(1, 0.1, 0.1, 0.1, DlColorSpace::kSRGB)); + builder.DrawPaint(background_paint); + + flutter::DlPaint paint; + + paint.setColor(flutter::DlColor::kRed().withAlpha(stroked_alpha)); + paint.setDrawStyle(flutter::DlDrawStyle::kStroke); + paint.setStrokeWidth(stroke_width + stroke_width_fine); + builder.Save(); + builder.Translate(250, 250); + builder.Scale(stroked_scale[0], stroked_scale[1]); + builder.Translate(-250, -250); + builder.DrawCircle(DlPoint(250, 250), stroked_radius, paint); + builder.Restore(); + return builder.Build(); + }; + + ASSERT_TRUE(OpenPlaygroundHere(callback)); +} + TEST_P(AiksTest, StrokedCirclesRenderCorrectly) { DisplayListBuilder builder; builder.Scale(GetContentScale().x, GetContentScale().y); diff --git a/engine/src/flutter/impeller/display_list/aiks_dl_runtime_effect_unittests.cc b/engine/src/flutter/impeller/display_list/aiks_dl_runtime_effect_unittests.cc index c24d7386482f6..62b364dd47c8d 100644 --- a/engine/src/flutter/impeller/display_list/aiks_dl_runtime_effect_unittests.cc +++ b/engine/src/flutter/impeller/display_list/aiks_dl_runtime_effect_unittests.cc @@ -14,6 +14,9 @@ #include "flutter/impeller/display_list/aiks_unittests.h" #include "flutter/impeller/display_list/dl_image_impeller.h" #include "flutter/impeller/display_list/dl_runtime_effect_impeller.h" +#include "imgui.h" +#include "impeller/geometry/point.h" +#include "impeller/geometry/vector.h" #include "third_party/abseil-cpp/absl/status/status_matchers.h" namespace impeller { @@ -324,8 +327,14 @@ TEST_P(AiksTest, ComposeBackdropRuntimeOuterBlurInner) { std::vector> sampler_inputs = { nullptr, }; + + struct FragUniforms { + Vector2 size; + Vector2 origin; + } frag_uniforms = {.size = Vector2(1, 1), .origin = Vector2(30.f, 30.f)}; auto uniform_data = std::make_shared>(); - uniform_data->resize(sizeof(Vector2)); + uniform_data->resize(sizeof(FragUniforms)); + memcpy(uniform_data->data(), &frag_uniforms, sizeof(FragUniforms)); auto runtime_filter = DlImageFilter::MakeRuntimeEffect( DlRuntimeEffectImpeller::Make(runtime_stage), sampler_inputs, @@ -383,8 +392,13 @@ TEST_P(AiksTest, ComposeBackdropRuntimeOuterBlurInnerSmallSigma) { std::vector> sampler_inputs = { nullptr, }; + struct FragUniforms { + Vector2 size; + Vector2 origin; + } frag_uniforms = {.size = Vector2(1, 1), .origin = Vector2(30.f, 30.f)}; auto uniform_data = std::make_shared>(); - uniform_data->resize(sizeof(Vector2)); + uniform_data->resize(sizeof(FragUniforms)); + memcpy(uniform_data->data(), &frag_uniforms, sizeof(FragUniforms)); auto runtime_filter = DlImageFilter::MakeRuntimeEffect( DlRuntimeEffectImpeller::Make(runtime_stage), sampler_inputs, @@ -414,6 +428,82 @@ TEST_P(AiksTest, ComposeBackdropRuntimeOuterBlurInnerSmallSigma) { ASSERT_TRUE(OpenPlaygroundHere(callback)); } +TEST_P(AiksTest, ClippedComposeBackdropRuntimeOuterBlurInnerSmallSigma) { + auto runtime_stages_result = + OpenAssetAsRuntimeStage("runtime_stage_filter_circle.frag.iplr"); + ABSL_ASSERT_OK(runtime_stages_result); + std::shared_ptr runtime_stage = + runtime_stages_result + .value()[PlaygroundBackendToRuntimeStageBackend(GetBackend())]; + ASSERT_TRUE(runtime_stage); + ASSERT_TRUE(runtime_stage->IsDirty()); + Scalar sigma = 5.0; + Vector2 clip_origin = Vector2(20.f, 20.f); + Vector2 clip_size = Vector2(300, 300); + Vector2 circle_origin = Vector2(30.f, 30.f); + + auto callback = [&]() -> sk_sp { + if (AiksTest::ImGuiBegin("Controls", nullptr, + ImGuiWindowFlags_AlwaysAutoResize)) { + ImGui::SliderFloat("sigma", &sigma, 0, 20); + ImGui::SliderFloat("clip_x", &clip_origin.x, 0, 2048.f); + ImGui::SliderFloat("clip_y", &clip_origin.y, 0, 1536.f); + ImGui::SliderFloat("clip_width", &clip_size.x, 0, 2048.f); + ImGui::SliderFloat("clip_height", &clip_size.y, 0, 1536.f); + ImGui::SliderFloat("circle_x", &circle_origin.x, 0.f, 2048.f); + ImGui::SliderFloat("circle_y", &circle_origin.y, 0.f, 1536.f); + ImGui::End(); + } + DisplayListBuilder builder; + DlPaint background; + background.setColor(DlColor(1.0, 0.1, 0.1, 0.1, DlColorSpace::kSRGB)); + builder.DrawPaint(background); + + auto blur_filter = + DlImageFilter::MakeBlur(sigma, sigma, DlTileMode::kClamp); + + std::vector> sampler_inputs = { + nullptr, + }; + struct FragUniforms { + Vector2 size; + Vector2 origin; + } frag_uniforms = {.size = Vector2(1, 1), .origin = circle_origin}; + auto uniform_data = std::make_shared>(); + uniform_data->resize(sizeof(FragUniforms)); + memcpy(uniform_data->data(), &frag_uniforms, sizeof(FragUniforms)); + + auto runtime_filter = DlImageFilter::MakeRuntimeEffect( + DlRuntimeEffectImpeller::Make(runtime_stage), sampler_inputs, + uniform_data); + + auto backdrop_filter = DlImageFilter::MakeCompose(/*outer=*/runtime_filter, + /*inner=*/blur_filter); + + builder.ClipRect(DlRect::MakeXYWH(clip_origin.x, clip_origin.y, clip_size.x, + clip_size.y)); + + DlPaint paint; + auto image = DlImageImpeller::Make(CreateTextureForFixture("kalimba.jpg")); + builder.DrawImage(image, DlPoint(100.0, 100.0), + DlImageSampling::kNearestNeighbor, &paint); + + DlPaint save_paint; + save_paint.setBlendMode(DlBlendMode::kSrc); + builder.SaveLayer(std::nullopt, &save_paint, backdrop_filter.get()); + builder.Restore(); + + DlPaint green; + green.setColor(DlColor::kGreen()); + builder.DrawLine({100, 100}, {200, 100}, green); + builder.DrawLine({100, 100}, {100, 200}, green); + + return builder.Build(); + }; + + ASSERT_TRUE(OpenPlaygroundHere(callback)); +} + TEST_P(AiksTest, ClippedBackdropFilterWithShader) { struct FragUniforms { Vector2 uSize; diff --git a/engine/src/flutter/impeller/display_list/dl_unittests.cc b/engine/src/flutter/impeller/display_list/dl_unittests.cc index f0f6ff4db6f46..02b948de12282 100644 --- a/engine/src/flutter/impeller/display_list/dl_unittests.cc +++ b/engine/src/flutter/impeller/display_list/dl_unittests.cc @@ -1442,6 +1442,7 @@ TEST_P(DisplayListTest, DrawCirclesWithTransformations) { static float filled_scale[2] = {1.0, 1.0}; static float stroked_radius = 20.0; static float stroke_width = 10.0; + static float stroke_width_fine = 0.0; static float stroked_alpha = 255.0; static float stroked_scale[2] = {1.0, 1.0}; @@ -1450,8 +1451,9 @@ TEST_P(DisplayListTest, DrawCirclesWithTransformations) { ImGui::SliderFloat("Filled Radius", &filled_radius, 0, 500); ImGui::SliderFloat("Filled Alpha", &filled_alpha, 0, 255); ImGui::SliderFloat2("Filled Scale", filled_scale, 0, 10.0); - ImGui::SliderFloat("Stroked Radius", &stroked_radius, 0, 10.0); + ImGui::SliderFloat("Stroked Radius", &stroked_radius, 0, 500); ImGui::SliderFloat("Stroked Width", &stroke_width, 0, 500); + ImGui::SliderFloat("Stroked Width Fine", &stroke_width_fine, 0, 5); ImGui::SliderFloat("Stroked Alpha", &stroked_alpha, 0, 10.0); ImGui::SliderFloat2("Stroked Scale", stroked_scale, 0, 10.0); } @@ -1469,7 +1471,7 @@ TEST_P(DisplayListTest, DrawCirclesWithTransformations) { paint.setColor(flutter::DlColor::kRed().withAlpha(stroked_alpha)); paint.setDrawStyle(flutter::DlDrawStyle::kStroke); - paint.setStrokeWidth(stroke_width); + paint.setStrokeWidth(stroke_width + stroke_width_fine); builder.Save(); builder.Scale(stroked_scale[0], stroked_scale[1]); builder.DrawCircle(DlPoint(1250, 750), stroked_radius, paint); diff --git a/engine/src/flutter/impeller/entity/contents/circle_contents.cc b/engine/src/flutter/impeller/entity/contents/circle_contents.cc index 590602a764bea..3fcf9a3202def 100644 --- a/engine/src/flutter/impeller/entity/contents/circle_contents.cc +++ b/engine/src/flutter/impeller/entity/contents/circle_contents.cc @@ -11,32 +11,20 @@ namespace impeller { namespace { -using BindFragmentCallback = std::function; using PipelineBuilderCallback = std::function; -using CreateGeometryCallback = - std::function; using VS = CirclePipeline::VertexShader; using FS = CirclePipeline::FragmentShader; -GeometryResult CreateGeometry(const ContentContext& renderer, - const Entity& entity, - RenderPass& pass, - const CircleGeometry* circle_geometry) { - auto geometry_result = - circle_geometry->GetPositionBuffer(renderer, entity, pass); - return geometry_result; -} +Scalar kAntialiasPixels = 1.0; } // namespace std::unique_ptr CircleContents::Make( std::unique_ptr geometry, Color color, bool stroked) { + geometry->SetAntialiasPadding(kAntialiasPixels); return std::unique_ptr( new CircleContents(std::move(geometry), color, stroked)); } @@ -57,11 +45,10 @@ bool CircleContents::Render(const ContentContext& renderer, frag_info.center = geometry_->GetCenter(); frag_info.radius = geometry_->GetRadius(); frag_info.stroke_width = geometry_->GetStrokeWidth(); - frag_info.aa_pixels = 1.0; + frag_info.aa_pixels = kAntialiasPixels; frag_info.stroked = stroked_ ? 1.0f : 0.0f; - auto geometry_result = - CreateGeometry(renderer, entity, pass, geometry_.get()); + auto geometry_result = geometry_->GetPositionBuffer(renderer, entity, pass); PipelineBuilderCallback pipeline_callback = [&renderer](ContentContextOptions options) { diff --git a/engine/src/flutter/impeller/entity/contents/filters/runtime_effect_filter_contents.cc b/engine/src/flutter/impeller/entity/contents/filters/runtime_effect_filter_contents.cc index 41138a46e5cbb..86c28aec0c3e3 100644 --- a/engine/src/flutter/impeller/entity/contents/filters/runtime_effect_filter_contents.cc +++ b/engine/src/flutter/impeller/entity/contents/filters/runtime_effect_filter_contents.cc @@ -11,6 +11,7 @@ #include "impeller/entity/contents/anonymous_contents.h" #include "impeller/entity/contents/runtime_effect_contents.h" #include "impeller/entity/contents/texture_contents.h" +#include "impeller/geometry/point.h" #include "impeller/geometry/size.h" namespace impeller { @@ -67,6 +68,8 @@ std::optional RuntimeEffectFilterContents::RenderFilter( // this branch for the unit test `ComposePaintRuntimeOuter`, but do it for // `ComposeBackdropRuntimeOuterBlurInner`. if (input_snapshot->ShouldRasterizeForRuntimeEffects()) { + Vector2 entity_offset = + Vector2(entity.GetTransform().m[12], entity.GetTransform().m[13]); Matrix inverse = input_snapshot->transform.Invert(); Quad quad = inverse.Transform(Quad{ coverage.GetLeftTop(), // @@ -84,10 +87,37 @@ std::optional RuntimeEffectFilterContents::RenderFilter( texture_contents.SetStencilEnabled(false); texture_contents.SetSamplerDescriptor(input_snapshot->sampler_descriptor); + // Use an AnonymousContents to restore the padding around the input that + // may have been cut out with a clip rect to maintain the correct + // coordinates for the fragment shader to perform. + auto anonymous_contents = AnonymousContents::Make( + [&texture_contents](const ContentContext& renderer, + const Entity& entity, RenderPass& pass) -> bool { + return texture_contents.Render(renderer, entity, pass); + }, + [maybe_input_coverage, + entity_offset](const Entity& entity) -> std::optional { + Rect coverage = maybe_input_coverage.value(); + // The LT values come from the offset of the clip rect, that creates + // the clipping effect on the content that will be rendered from + // the fragment shader. The RB values define the region we'll be + // synthesizing and ultimately defines the width and the height of + // the rasterized image. The LT values can be thought of shifting + // the window that will be rasterized. Since we are shifting from + // the top-left corner, that is effectively pushing the the bottom + // right corner lower, outside of the rendering space. So, we can + // clamp those values to the coverage's RB values. This doesn't + // cause the fragment shader's rendering to deform because the + // magic width/height values sent to the fragment shader don't take + // the rasterized image's size into account. + return Rect::MakeLTRB(entity_offset.x, entity_offset.y, + coverage.GetRight(), coverage.GetBottom()); + }); + Entity entity; // In order to maintain precise coordinates in the fragment shader we need // to eliminate the padding typically given to RenderToSnapshot results. - input_snapshot = texture_contents.RenderToSnapshot( + input_snapshot = anonymous_contents->RenderToSnapshot( renderer, entity, {.coverage_expansion = 0}); if (!input_snapshot.has_value()) { return std::nullopt; diff --git a/engine/src/flutter/impeller/entity/geometry/circle_geometry.cc b/engine/src/flutter/impeller/entity/geometry/circle_geometry.cc index 5db7120ac266e..b0b43dd5e1f77 100644 --- a/engine/src/flutter/impeller/entity/geometry/circle_geometry.cc +++ b/engine/src/flutter/impeller/entity/geometry/circle_geometry.cc @@ -9,11 +9,15 @@ #include "flutter/impeller/entity/geometry/line_geometry.h" #include "impeller/core/formats.h" #include "impeller/entity/geometry/geometry.h" +#include "impeller/geometry/scalar.h" namespace impeller { CircleGeometry::CircleGeometry(const Point& center, Scalar radius) - : center_(center), radius_(radius), stroke_width_(-1.0f) { + : center_(center), + radius_(radius), + stroke_width_(-1.0f), + padding_pixels_(0.0f) { FML_DCHECK(radius >= 0); } @@ -24,7 +28,8 @@ CircleGeometry::CircleGeometry(const Point& center, Scalar stroke_width) : center_(center), radius_(radius), - stroke_width_(std::max(stroke_width, 0.0f)) { + stroke_width_(std::max(stroke_width, 0.0f)), + padding_pixels_(0.0) { FML_DCHECK(radius >= 0); FML_DCHECK(stroke_width >= 0); } @@ -49,26 +54,39 @@ Scalar CircleGeometry::GetStrokeWidth() const { return stroke_width_; } +void CircleGeometry::SetAntialiasPadding(Scalar extra_padding) { + padding_pixels_ = extra_padding; +} + GeometryResult CircleGeometry::GetPositionBuffer(const ContentContext& renderer, const Entity& entity, RenderPass& pass) const { auto& transform = entity.GetTransform(); - Scalar half_width = stroke_width_ < 0 ? 0.0 - : LineGeometry::ComputePixelHalfWidth( - transform, stroke_width_); + Scalar max_basis = transform.GetMaxBasisLengthXY(); + Scalar expansion = max_basis == 0 ? 0.0 : padding_pixels_ / max_basis; - // We call the StrokedCircle method which will simplify to a - // FilledCircleGenerator if the inner_radius is <= 0. - auto generator = renderer.GetTessellator().StrokedCircle(transform, center_, - radius_, half_width); + if (stroke_width_ < 0) { + auto generator = renderer.GetTessellator().FilledCircle( + transform, center_, radius_ + expansion); + return ComputePositionGeometry(renderer, generator, entity, pass); + } + + Scalar half_width = + LineGeometry::ComputePixelHalfWidth(transform, stroke_width_); + + auto generator = renderer.GetTessellator().StrokedCircle( + transform, center_, radius_, half_width + expansion); return ComputePositionGeometry(renderer, generator, entity, pass); } std::optional CircleGeometry::GetCoverage(const Matrix& transform) const { + Scalar max_basis = transform.GetMaxBasisLengthXY(); + Scalar expansion = max_basis == 0 ? 0.0 : padding_pixels_ / max_basis; + Scalar half_width = stroke_width_ < 0 ? 0.0 : stroke_width_ * 0.5f; - Scalar outer_radius = radius_ + half_width; + Scalar outer_radius = radius_ + half_width + expansion; return Rect::MakeLTRB(-outer_radius, -outer_radius, // +outer_radius, +outer_radius) .Shift(center_) diff --git a/engine/src/flutter/impeller/entity/geometry/circle_geometry.h b/engine/src/flutter/impeller/entity/geometry/circle_geometry.h index e20b75494881c..a462a634ef3a6 100644 --- a/engine/src/flutter/impeller/entity/geometry/circle_geometry.h +++ b/engine/src/flutter/impeller/entity/geometry/circle_geometry.h @@ -42,10 +42,15 @@ class CircleGeometry final : public Geometry { const Entity& entity, RenderPass& pass) const override; + // Set the number of pixels to add to the edge(s) of the circle for + // SDF-based antialiasing + void SetAntialiasPadding(Scalar extra_pixels); + private: Point center_; Scalar radius_; Scalar stroke_width_; + Scalar padding_pixels_; CircleGeometry(const CircleGeometry&) = delete; diff --git a/engine/src/flutter/impeller/entity/geometry/shadow_path_geometry.cc b/engine/src/flutter/impeller/entity/geometry/shadow_path_geometry.cc index e0b97a062fb4f..aed03ee4030ab 100644 --- a/engine/src/flutter/impeller/entity/geometry/shadow_path_geometry.cc +++ b/engine/src/flutter/impeller/entity/geometry/shadow_path_geometry.cc @@ -1359,8 +1359,11 @@ uint16_t PolygonInfo::AppendVertex(const Point& vertex, Scalar gaussian) { FML_DCHECK(index == gaussians_.size()); // TODO(jimgraham): Turn this condition into a failure of the tessellation FML_DCHECK(index <= std::numeric_limits::max()); - if (gaussian == gaussians_.back() && vertex == vertices_.back()) { - return index - 1; + if (index > 0u) { + FML_DCHECK(!gaussians_.empty() && !vertices_.empty()); + if (gaussian == gaussians_.back() && vertex == vertices_.back()) { + return index - 1; + } } vertices_.push_back(vertex); gaussians_.push_back(gaussian); diff --git a/engine/src/flutter/impeller/entity/shaders/circle.frag b/engine/src/flutter/impeller/entity/shaders/circle.frag index 5dafa5a03b337..998574e2746de 100644 --- a/engine/src/flutter/impeller/entity/shaders/circle.frag +++ b/engine/src/flutter/impeller/entity/shaders/circle.frag @@ -21,51 +21,74 @@ out vec4 frag_color; highp in vec2 v_position; -float distanceFromCircle(float radius, vec2 center, vec2 point) { - return distance(point, center) - radius; +float distanceFromCircle(float dist_to_center, float radius) { + return dist_to_center - radius; } -float distanceFromStrokedCircle(float radius, - float stroke_width, - vec2 center, - vec2 point) { - float half_stroke = stroke_width / 2.0; - - float inner_radius = radius - half_stroke; - - float outer_radius = radius + half_stroke; - - float outer_distance = distanceFromCircle(outer_radius, center, point); - - float inner_distance = -distanceFromCircle(inner_radius, center, point); - - return max(inner_distance, outer_distance); +float distanceFromStrokedCircle(float dist_to_center, + float radius, + float half_stroke_width) { + float outer_radius = radius + half_stroke_width; + float inner_radius = radius - half_stroke_width; + + float outer_distance = distanceFromCircle(dist_to_center, outer_radius); + float inner_distance = distanceFromCircle(dist_to_center, inner_radius); + + // If a point lies inside of the hole of the stroked circle, the + // inner_distance will be negative. -inner_distance is then a value that's + // positive inside of the hole, zero at the inner boundary, and negative + // elsewhere. outer_distance is a value that's negative inside the the outer + // radius of the circle, zero at the outer boundary, and positive elsewhere. + // If a point lies: + // - inside the hole -> max(-inner_distance, outer_distance) > 0 + // - outside the outer radius -> max(-inner_distance, outer_distance) > 0 + // - on the stroke boundary -> max(-inner_distance, outer_distance) = 0 + // - inside of the stroke -> max(-inner_distance, outer_distance) < 0. With + // the value minimal at the radius, and approaching zero at the boundaries + return max(-inner_distance, outer_distance); } void main() { - float dist_filled = - distanceFromCircle(frag_info.radius, frag_info.center, v_position); + // We need to make sure that for stroked circles we have a cross section + // that is at least as wide as a pixel. The cross section is measured towards + // the center of the circle which is the same direction in both local and + // screen coordinates for uniformly scaled transforms and only slightly off + // for skewed transforms. + vec2 vec_to_center = v_position - frag_info.center; + float dist_to_center = length(vec_to_center); + vec2 unitvec_towards_center = + dist_to_center > 0.0 ? vec_to_center / dist_to_center : vec2(1.0, 0.0); + + // Get the width and height of a pixel in v_position units. + // This gives us a basis in local space coordinates to work in for calculating + // the SDF. + float local_dx = length(dFdx(v_position)); + float local_dy = length(dFdy(v_position)); + + // Get the length of the vector towards the center of the circle measured in + // local coordinates. + float local_dist_towards_center = + dot(vec2(local_dx, local_dy), abs(unitvec_towards_center)); + + float adjusted_stroke_width = + max(frag_info.stroke_width, local_dist_towards_center); + + float dist_filled = distanceFromCircle(dist_to_center, frag_info.radius); float dist_stroked = distanceFromStrokedCircle( - frag_info.radius, frag_info.stroke_width, frag_info.center, v_position); - float sdf_distance = mix(dist_filled, dist_stroked, frag_info.stroked); + dist_to_center, frag_info.radius, adjusted_stroke_width * 0.5f); - float pixel_derivative_sdf = fwidth(sdf_distance); + float sdf_distance = mix(dist_filled, dist_stroked, frag_info.stroked); - // If the screen space derivative is less than the stroke width, - // only one pixel can be covered and shouldn't be faded. - if (frag_info.stroked > 0.0 && - pixel_derivative_sdf * 2.0 >= frag_info.stroke_width) { - sdf_distance = -frag_info.radius; - } + // Calculate the size of the anti-aliasing fade region in SDF units. + // This should correspond to roughly half a pixel's width on screen, scaled by + // the aa_pixels factor. + float fade_size = local_dist_towards_center * frag_info.aa_pixels * 0.5; - float fade_width = pixel_derivative_sdf * frag_info.aa_pixels; - // The sdf_distance will be -pixel_derivative_sdf*N exactly at N pixels away - // from the edge of the circle - float alpha = 1.0 - smoothstep(-fade_width, 0.0, sdf_distance); + float alpha = 1.0 - smoothstep(-fade_size, fade_size, sdf_distance); - float finalAlpha = frag_info.color.w * alpha; + float finalAlpha = frag_info.color.a * alpha; - frag_color = vec4(frag_info.color.xyz, finalAlpha); + frag_color = vec4(frag_info.color.rgb, finalAlpha); frag_color = IPPremultiply(frag_color); } diff --git a/engine/src/flutter/impeller/fixtures/runtime_stage_filter_circle.frag b/engine/src/flutter/impeller/fixtures/runtime_stage_filter_circle.frag index 58bae1eb8f6dc..423e77f9fcd07 100644 --- a/engine/src/flutter/impeller/fixtures/runtime_stage_filter_circle.frag +++ b/engine/src/flutter/impeller/fixtures/runtime_stage_filter_circle.frag @@ -6,10 +6,10 @@ uniform vec2 u_size; uniform sampler2D u_texture; +uniform vec2 u_origin; out vec4 frag_color; -vec2 origin = vec2(30.0, 30.0); float radius = 30.0; void main() { @@ -18,7 +18,7 @@ void main() { #ifdef IMPELLER_TARGET_OPENGLES fixed_uv.y = 1.0 - fixed_uv.y; #endif - vec2 norm_origin = origin / u_size; + vec2 norm_origin = u_origin / u_size; float norm_radius = radius / max(u_size.x, u_size.y); if (distance(uv, norm_origin) < norm_radius) { frag_color = vec4(1.0, 0.0, 0.0, 1.0); diff --git a/engine/src/flutter/impeller/renderer/backend/vulkan/swapchain/khr/khr_swapchain_impl_vk.cc b/engine/src/flutter/impeller/renderer/backend/vulkan/swapchain/khr/khr_swapchain_impl_vk.cc index d1a8bd3b0869e..06b47a0c6f330 100644 --- a/engine/src/flutter/impeller/renderer/backend/vulkan/swapchain/khr/khr_swapchain_impl_vk.cc +++ b/engine/src/flutter/impeller/renderer/backend/vulkan/swapchain/khr/khr_swapchain_impl_vk.cc @@ -22,6 +22,7 @@ static constexpr size_t kMaxFramesInFlight = 2u; struct KHRFrameSynchronizerVK { vk::UniqueFence acquire; + bool acquire_fence_pending = false; vk::UniqueSemaphore render_ready; std::shared_ptr final_cmd_buffer; bool is_valid = false; @@ -29,8 +30,7 @@ struct KHRFrameSynchronizerVK { bool has_onscreen = false; explicit KHRFrameSynchronizerVK(const vk::Device& device) { - auto acquire_res = device.createFenceUnique( - vk::FenceCreateInfo{vk::FenceCreateFlagBits::eSignaled}); + auto acquire_res = device.createFenceUnique({}); auto render_res = device.createSemaphoreUnique({}); if (acquire_res.result != vk::Result::eSuccess || render_res.result != vk::Result::eSuccess) { @@ -45,6 +45,9 @@ struct KHRFrameSynchronizerVK { ~KHRFrameSynchronizerVK() = default; bool WaitForFence(const vk::Device& device) { + if (!acquire_fence_pending) { + return true; + } if (auto result = device.waitForFences( *acquire, // fence true, // wait all @@ -54,6 +57,7 @@ struct KHRFrameSynchronizerVK { VALIDATION_LOG << "Fence wait failed: " << vk::to_string(result); return false; } + acquire_fence_pending = false; if (auto result = device.resetFences(*acquire); result != vk::Result::eSuccess) { VALIDATION_LOG << "Could not reset fence: " << vk::to_string(result); @@ -485,6 +489,7 @@ bool KHRSwapchainImplVK::Present( << vk::to_string(result); return false; } + sync->acquire_fence_pending = true; } //---------------------------------------------------------------------------- diff --git a/engine/src/flutter/impeller/renderer/backend/vulkan/test/mock_vulkan.cc b/engine/src/flutter/impeller/renderer/backend/vulkan/test/mock_vulkan.cc index 4f2215287fabc..c0e2ad12987cb 100644 --- a/engine/src/flutter/impeller/renderer/backend/vulkan/test/mock_vulkan.cc +++ b/engine/src/flutter/impeller/renderer/backend/vulkan/test/mock_vulkan.cc @@ -541,11 +541,18 @@ VkResult vkQueueSubmit(VkQueue queue, return VK_SUCCESS; } +static thread_local std::function> + g_wait_for_fences_callback; + VkResult vkWaitForFences(VkDevice device, uint32_t fenceCount, const VkFence* pFences, VkBool32 waitAll, uint64_t timeout) { + if (g_wait_for_fences_callback) { + return g_wait_for_fences_callback(device, fenceCount, pFences, waitAll, + timeout); + } return VK_SUCCESS; } @@ -753,12 +760,20 @@ void vkDestroySemaphore(VkDevice device, delete reinterpret_cast(semaphore); } +static thread_local std::function< + std::remove_pointer_t> + g_acquire_next_image_callback; + VkResult vkAcquireNextImageKHR(VkDevice device, VkSwapchainKHR swapchain, uint64_t timeout, VkSemaphore semaphore, VkFence fence, uint32_t* pImageIndex) { + if (g_acquire_next_image_callback) { + return g_acquire_next_image_callback(device, swapchain, timeout, semaphore, + fence, pImageIndex); + } auto current_index = reinterpret_cast(swapchain)->current_image++; *pImageIndex = (current_index + 1) % 3u; @@ -997,6 +1012,8 @@ std::shared_ptr MockVulkanContextBuilder::Build() { g_instance_layers = instance_layers_; g_format_properties_callback = format_properties_callback_; g_physical_device_properties_callback = physical_properties_callback_; + g_acquire_next_image_callback = acquire_next_image_callback_; + g_wait_for_fences_callback = wait_for_fences_callback_; settings.embedder_data = embedder_data_; std::shared_ptr result = ContextVK::Create(std::move(settings)); return result; diff --git a/engine/src/flutter/impeller/renderer/backend/vulkan/test/mock_vulkan.h b/engine/src/flutter/impeller/renderer/backend/vulkan/test/mock_vulkan.h index ff28213cf2310..79772c1fc6529 100644 --- a/engine/src/flutter/impeller/renderer/backend/vulkan/test/mock_vulkan.h +++ b/engine/src/flutter/impeller/renderer/backend/vulkan/test/mock_vulkan.h @@ -114,6 +114,20 @@ class MockVulkanContextBuilder { return *this; } + MockVulkanContextBuilder SetAcquireNextImageCallback( + std::function> + acquire_next_image_callback) { + acquire_next_image_callback_ = std::move(acquire_next_image_callback); + return *this; + } + + MockVulkanContextBuilder SetWaitForFencesCallback( + std::function> + wait_for_fences_callback) { + wait_for_fences_callback_ = std::move(wait_for_fences_callback); + return *this; + } + private: std::function settings_callback_; std::vector instance_extensions_; @@ -126,6 +140,10 @@ class MockVulkanContextBuilder { std::function physical_properties_callback_; + std::function> + acquire_next_image_callback_; + std::function> + wait_for_fences_callback_; }; /// @brief Override the image size returned by all swapchain images. diff --git a/engine/src/flutter/impeller/renderer/backend/vulkan/test/swapchain_unittests.cc b/engine/src/flutter/impeller/renderer/backend/vulkan/test/swapchain_unittests.cc index 08025704f400a..847ba0938a2f8 100644 --- a/engine/src/flutter/impeller/renderer/backend/vulkan/test/swapchain_unittests.cc +++ b/engine/src/flutter/impeller/renderer/backend/vulkan/test/swapchain_unittests.cc @@ -137,5 +137,31 @@ TEST(SwapchainTest, CachesRenderPassOnSwapchainImage) { } } +TEST(SwapchainTest, NoFenceWaitAfterAcquireNextImageFailure) { + bool wait_for_fences_called = false; + auto const context = + MockVulkanContextBuilder() + .SetAcquireNextImageCallback( + [](VkDevice, VkSwapchainKHR, uint64_t, VkSemaphore, VkFence, + uint32_t*) -> VkResult { return VK_ERROR_SURFACE_LOST_KHR; }) + .SetWaitForFencesCallback([&](VkDevice, uint32_t, const VkFence*, + VkBool32, uint64_t) -> VkResult { + wait_for_fences_called = true; + return VK_SUCCESS; + }) + .Build(); + + auto surface = CreateSurface(*context); + SetSwapchainImageSize(ISize{1, 1}); + auto swapchain = + KHRSwapchainVK::Create(context, std::move(surface), ISize{1, 1}, + /*enable_msaa=*/false); + auto image = swapchain->AcquireNextDrawable(); + EXPECT_FALSE(image); + + swapchain->AcquireNextDrawable(); + EXPECT_FALSE(wait_for_fences_called); +} + } // namespace testing } // namespace impeller diff --git a/engine/src/flutter/impeller/toolkit/interop/README.md b/engine/src/flutter/impeller/toolkit/interop/README.md index 6e75a53a11775..279379217e143 100644 --- a/engine/src/flutter/impeller/toolkit/interop/README.md +++ b/engine/src/flutter/impeller/toolkit/interop/README.md @@ -27,7 +27,7 @@ A single-header C API for 2D graphics and text rendering. [Impeller](../../READM Users may plug in a custom toolchain into the Flutter Engine build system to build the `libimpeller.so` dynamic library. However, for the common platforms, the CI bots upload a tarball containing the library and headers. This URL for the SDK tarball for a particular platform can be constructed as follows: ```sh -https://storage.googleapis.com/flutter_infra_release/flutter/$FLUTTER_SHA/$PLATFORM_ARCH/impeller_sdk.zip +https://download.shorebird.dev/flutter_infra_release/flutter/$FLUTTER_SHA/$PLATFORM_ARCH/impeller_sdk.zip ``` The `$FLUTTER_SHA` is the Git hash in the [Flutter repository](https://github.com/flutter/flutter). The `$PLATFORM_ARCH` can be determined from the table below. diff --git a/engine/src/flutter/impeller/tools/malioc.json b/engine/src/flutter/impeller/tools/malioc.json index 734fd06a2d505..7399bf1cdb8df 100644 --- a/engine/src/flutter/impeller/tools/malioc.json +++ b/engine/src/flutter/impeller/tools/malioc.json @@ -270,7 +270,7 @@ "uses_late_zs_update": false, "variants": { "Main": { - "fp16_arithmetic": 37, + "fp16_arithmetic": 46, "has_stack_spilling": false, "performance": { "longest_path_bound_pipelines": [ @@ -278,10 +278,10 @@ "arith_sfu" ], "longest_path_cycles": [ - 0.375, - 0.359375, - 0.125, - 0.375, + 0.6875, + 0.59375, + 0.171875, + 0.6875, 0.0, 0.25, 0.0 @@ -300,10 +300,10 @@ "arith_sfu" ], "shortest_path_cycles": [ - 0.375, - 0.359375, - 0.125, - 0.375, + 0.6875, + 0.59375, + 0.171875, + 0.6875, 0.0, 0.25, 0.0 @@ -313,10 +313,10 @@ "arith_sfu" ], "total_cycles": [ - 0.375, - 0.359375, - 0.125, - 0.375, + 0.6875, + 0.59375, + 0.171875, + 0.6875, 0.0, 0.25, 0.0 @@ -324,8 +324,8 @@ }, "stack_spill_bytes": 0, "thread_occupancy": 100, - "uniform_registers_used": 12, - "work_registers_used": 8 + "uniform_registers_used": 10, + "work_registers_used": 16 } } } @@ -2182,7 +2182,7 @@ "uses_late_zs_update": false, "variants": { "Main": { - "fp16_arithmetic": 60, + "fp16_arithmetic": 35, "has_stack_spilling": false, "performance": { "longest_path_bound_pipelines": [ @@ -2190,10 +2190,10 @@ "arith_fma" ], "longest_path_cycles": [ - 0.453125, - 0.453125, - 0.140625, - 0.375, + 0.699999988079071, + 0.699999988079071, + 0.234375, + 0.6875, 0.0, 0.25, 0.0 @@ -2212,10 +2212,10 @@ "arith_fma" ], "shortest_path_cycles": [ - 0.453125, - 0.453125, - 0.109375, - 0.375, + 0.699999988079071, + 0.699999988079071, + 0.140625, + 0.6875, 0.0, 0.25, 0.0 @@ -2225,10 +2225,10 @@ "arith_fma" ], "total_cycles": [ - 0.453125, - 0.453125, - 0.140625, - 0.375, + 0.699999988079071, + 0.699999988079071, + 0.234375, + 0.6875, 0.0, 0.25, 0.0 @@ -2237,7 +2237,7 @@ "stack_spill_bytes": 0, "thread_occupancy": 100, "uniform_registers_used": 10, - "work_registers_used": 23 + "work_registers_used": 25 } } }, @@ -2254,7 +2254,7 @@ "arithmetic" ], "longest_path_cycles": [ - 5.610000133514404, + 6.269999980926514, 1.0, 2.0 ], @@ -2267,7 +2267,7 @@ "arithmetic" ], "shortest_path_cycles": [ - 5.610000133514404, + 6.269999980926514, 1.0, 2.0 ], @@ -2275,14 +2275,14 @@ "arithmetic" ], "total_cycles": [ - 6.0, + 6.666666507720947, 1.0, 2.0 ] }, "thread_occupancy": 100, "uniform_registers_used": 2, - "work_registers_used": 2 + "work_registers_used": 3 } } } diff --git a/engine/src/flutter/lib/snapshot/BUILD.gn b/engine/src/flutter/lib/snapshot/BUILD.gn index e4b52cac21985..e495383162a89 100644 --- a/engine/src/flutter/lib/snapshot/BUILD.gn +++ b/engine/src/flutter/lib/snapshot/BUILD.gn @@ -35,7 +35,10 @@ group("generate_snapshot_bins") { if (host_os == "mac" && (target_os == "mac" || target_os == "ios" || target_os == "android")) { # For macOS target builds: needed for both target CPUs (arm64, x64). - public_deps += [ ":create_macos_gen_snapshots" ] + public_deps += [ + ":create_macos_analyze_snapshots", + ":create_macos_gen_snapshots", + ] } else if (host_os == "mac" && (target_cpu == "arm" || target_cpu == "arm64")) { # For iOS, Android target builds: all AOT target CPUs are arm/arm64. @@ -46,9 +49,11 @@ group("generate_snapshot_bins") { public_deps = [ "$dart_src/runtime/bin:gen_snapshot($host_toolchain)" ] } - # Build analyze_snapshot for 64-bit target CPUs. - if (host_os == "linux" && (target_cpu == "x64" || target_cpu == "arm64" || - target_cpu == "riscv64")) { + # Build analyze_snapshot for 64-bit target CPUs on linux. + # Or always targeting arm64 for Shorebird builds. + if ((host_os == "linux" && + (target_cpu == "x64" || target_cpu == "riscv64")) || + target_cpu == "arm64") { public_deps += [ "$dart_src/runtime/bin:analyze_snapshot($host_toolchain)" ] } } @@ -257,6 +262,69 @@ if (host_os == "mac" && ":create_macos_gen_snapshot_x64${gen_snapshot_suffix}", ] } + + # Added by shorebird. + # analyze_snapshot targets below were copied from the gen_snapshot targets + # above to allow us to include analyze_snapshot in the artifacts generated + # for create_ios_framework.py. + template("build_mac_analyze_snapshot") { + assert(defined(invoker.host_arch)) + host_cpu = invoker.host_arch + + build_toolchain = "//build/toolchain/mac:clang_$host_cpu" + analyze_snapshot_target_name = "analyze_snapshot" + + # At this point, the gen_snapshot equivalent changes + # gen_ snapshot_target_name to "gen_snapshot_host_targeting_host". There is + # no equivalent for analyze_snapshot, so we don't do that here. + # + # It's unclear whether we need to do so now, but we didn't previously, so + # we're not doing it now until we have a reason to. + + analyze_snapshot_target = + "$dart_src/runtime/bin:$analyze_snapshot_target_name($build_toolchain)" + + copy(target_name) { + # The toolchain-specific output directory. For cross-compiles, this is a + # clang-x64 or clang-arm64 subdirectory of the top-level build directory. + output_dir = get_label_info(analyze_snapshot_target, "root_out_dir") + + sources = [ "${output_dir}/${analyze_snapshot_target_name}" ] + outputs = [ + "${root_out_dir}/artifacts_$host_cpu/analyze_snapshot_${target_cpu}", + ] + deps = [ analyze_snapshot_target ] + } + } + + build_mac_analyze_snapshot( + "create_macos_analyze_snapshot_arm64_${target_cpu}") { + host_arch = "arm64" + } + + build_mac_analyze_snapshot( + "create_macos_analyze_snapshot_x64_${target_cpu}") { + host_arch = "x64" + } + + action("create_macos_analyze_snapshots") { + script = "//flutter/sky/tools/create_macos_binary.py" + outputs = [ "${root_out_dir}/analyze_snapshot_${target_cpu}" ] + args = [ + "--in-arm64", + rebase_path( + "${root_out_dir}/artifacts_arm64/analyze_snapshot_${target_cpu}"), + "--in-x64", + rebase_path( + "${root_out_dir}/artifacts_x64/analyze_snapshot_${target_cpu}"), + "--out", + rebase_path("${root_out_dir}/analyze_snapshot_${target_cpu}"), + ] + deps = [ + ":create_macos_analyze_snapshot_arm64_${target_cpu}", + ":create_macos_analyze_snapshot_x64_${target_cpu}", + ] + } } source_set("snapshot") { diff --git a/engine/src/flutter/lib/ui/fixtures/2_dispose_op_restore_previous.apng.67.png b/engine/src/flutter/lib/ui/fixtures/2_dispose_op_restore_previous.apng.67.png index e71bfa885f0ea..bdfede4eebb71 100644 Binary files a/engine/src/flutter/lib/ui/fixtures/2_dispose_op_restore_previous.apng.67.png and b/engine/src/flutter/lib/ui/fixtures/2_dispose_op_restore_previous.apng.67.png differ diff --git a/engine/src/flutter/lib/ui/fixtures/2_dispose_op_restore_previous.apng.68.png b/engine/src/flutter/lib/ui/fixtures/2_dispose_op_restore_previous.apng.68.png index 3d698155271cc..1e3ab0c488b60 100644 Binary files a/engine/src/flutter/lib/ui/fixtures/2_dispose_op_restore_previous.apng.68.png and b/engine/src/flutter/lib/ui/fixtures/2_dispose_op_restore_previous.apng.68.png differ diff --git a/engine/src/flutter/lib/ui/fixtures/2_dispose_op_restore_previous.apng.69.png b/engine/src/flutter/lib/ui/fixtures/2_dispose_op_restore_previous.apng.69.png index 1ee3dd7f4a2c2..f200430396aa4 100644 Binary files a/engine/src/flutter/lib/ui/fixtures/2_dispose_op_restore_previous.apng.69.png and b/engine/src/flutter/lib/ui/fixtures/2_dispose_op_restore_previous.apng.69.png differ diff --git a/engine/src/flutter/lib/ui/fixtures/four_frame_with_reuse_end.png b/engine/src/flutter/lib/ui/fixtures/four_frame_with_reuse_end.png index 5e7f54a087f60..9938adf3529c7 100644 Binary files a/engine/src/flutter/lib/ui/fixtures/four_frame_with_reuse_end.png and b/engine/src/flutter/lib/ui/fixtures/four_frame_with_reuse_end.png differ diff --git a/engine/src/flutter/lib/ui/fixtures/heart_end.png b/engine/src/flutter/lib/ui/fixtures/heart_end.png index 1c24c59dc9111..6d653ef3dcb20 100644 Binary files a/engine/src/flutter/lib/ui/fixtures/heart_end.png and b/engine/src/flutter/lib/ui/fixtures/heart_end.png differ diff --git a/engine/src/flutter/lib/ui/painting/image_decoder_unittests.cc b/engine/src/flutter/lib/ui/painting/image_decoder_unittests.cc index 2420716622e1c..c66e817687ec7 100644 --- a/engine/src/flutter/lib/ui/painting/image_decoder_unittests.cc +++ b/engine/src/flutter/lib/ui/painting/image_decoder_unittests.cc @@ -949,26 +949,6 @@ TEST(ImageDecoderTest, VerifySubpixelDecodingPreservesExifOrientation) { ASSERT_TRUE(image != nullptr); ASSERT_EQ(600, image->width()); ASSERT_EQ(200, image->height()); - - auto decode = [descriptor](uint32_t target_width, uint32_t target_height) { - return ImageDecoderSkia::ImageFromCompressedData( - descriptor.get(), target_width, target_height, - fml::tracing::TraceFlow("")); - }; - - auto expected_data = flutter::testing::OpenFixtureAsSkData("Horizontal.png"); - ASSERT_TRUE(expected_data != nullptr); - ASSERT_FALSE(expected_data->isEmpty()); - - auto assert_image = [&](const auto& decoded_image, - const std::string& decode_error) { - ASSERT_EQ(decoded_image->dimensions(), SkISize::Make(300, 100)); - sk_sp encoded = - SkPngEncoder::Encode(nullptr, decoded_image.get(), {}); - ASSERT_TRUE(encoded->equals(expected_data.get())); - }; - - assert_image(decode(300, 100), {}); } TEST_F(ImageDecoderFixtureTest, diff --git a/engine/src/flutter/lib/ui/painting/image_generator_apng.cc b/engine/src/flutter/lib/ui/painting/image_generator_apng.cc index 08182c9ff2bd4..9e2d52986f78e 100644 --- a/engine/src/flutter/lib/ui/painting/image_generator_apng.cc +++ b/engine/src/flutter/lib/ui/painting/image_generator_apng.cc @@ -7,6 +7,7 @@ #include #include "flutter/fml/logging.h" +#include "flutter/fml/safe_math.h" #include "third_party/skia/include/codec/SkCodec.h" #include "third_party/skia/include/codec/SkCodecAnimation.h" #include "third_party/skia/include/core/SkAlphaType.h" @@ -91,10 +92,26 @@ bool APNGImageGenerator::GetPixels(const SkImageInfo& info, APNGImage& frame = images_[image_index]; SkImageInfo frame_info = frame.codec->getInfo(); - auto frame_row_bytes = frame_info.bytesPerPixel() * frame_info.width(); + + fml::SafeMath safe; + size_t frame_row_bytes = + safe.mul(frame_info.bytesPerPixel(), frame_info.width()); + if (safe.overflow_detected()) { + FML_DLOG(ERROR) << "Failed to decode image at index " << image_index + << " (frame index: " << frame_index + << ") of APNG due to frame row bytes overflow."; + return false; + } if (frame.pixels.empty()) { - frame.pixels.resize(frame_row_bytes * frame_info.height()); + size_t pixels_bytes = safe.mul(frame_row_bytes, frame_info.height()); + if (safe.overflow_detected()) { + FML_DLOG(ERROR) << "Failed to decode image at index " << image_index + << " (frame index: " << frame_index + << ") of APNG due to pixel buffer size overflow."; + return false; + } + frame.pixels.resize(pixels_bytes); SkCodec::Result result = frame.codec->getPixels( frame.codec->getInfo(), frame.pixels.data(), frame_row_bytes); if (result != SkCodec::kSuccess) { diff --git a/engine/src/flutter/lib/web_ui/dev/steps/copy_artifacts_step.dart b/engine/src/flutter/lib/web_ui/dev/steps/copy_artifacts_step.dart index e95e0b3f0a4b3..5d94008deed48 100644 --- a/engine/src/flutter/lib/web_ui/dev/steps/copy_artifacts_step.dart +++ b/engine/src/flutter/lib/web_ui/dev/steps/copy_artifacts_step.dart @@ -55,8 +55,8 @@ class CopyArtifactsStep implements PipelineStep { 'Could not generate artifact bucket url for unknown realm.', ), }; - final url = Uri.https( - 'storage.googleapis.com', + final Uri url = Uri.https( + 'download.shorebird.dev', '${realmComponent}flutter_infra_release/flutter/${realm == LuciRealm.Try ? gitRevision : contentHash}/flutter-web-sdk.zip', ); final http.Response response = await http.Client().get(url); diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/compositing/canvas_provider.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/compositing/canvas_provider.dart index f48fa59be086b..b9f55e0230ba9 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/compositing/canvas_provider.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/compositing/canvas_provider.dart @@ -105,6 +105,7 @@ class OnscreenCanvasProvider extends CanvasProvider { final cssHeight = '${size.height / ratio}px'; canvas.style ..width = cssWidth - ..height = cssHeight; + ..height = cssHeight + ..position = 'absolute'; } } diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/renderer.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/renderer.dart index d996582c66dd6..a64885d2aaf02 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/renderer.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/renderer.dart @@ -67,6 +67,10 @@ abstract class Renderer { @mustCallSuper FutureOr initialize() { + _setUpViewListeners(); + } + + void _setUpViewListeners() { // Views may have been registered before this renderer was initialized. // Create rasterizers for them and then start listening for new view // creation/disposal events. @@ -347,8 +351,12 @@ abstract class Renderer { /// Clears the state of this renderer. Used in tests. @mustCallSuper void debugClear() { + _onViewCreatedListener.cancel(); + _onViewDisposedListener.cancel(); for (final ViewRasterizer rasterizer in rasterizers.values) { - rasterizer.debugClear(); + rasterizer.dispose(); } + rasterizers.clear(); + _setUpViewListeners(); } } diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/skwasm/skwasm_impl/raw/raw_memory.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/skwasm/skwasm_impl/raw/raw_memory.dart index e6147e044cd05..939e62f2206fc 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/skwasm/skwasm_impl/raw/raw_memory.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/skwasm/skwasm_impl/raw/raw_memory.dart @@ -5,6 +5,7 @@ @DefaultAsset('skwasm') library skwasm_impl; +import 'dart:async'; import 'dart:convert'; import 'dart:ffi'; import 'dart:typed_data'; @@ -231,6 +232,13 @@ class StackScope { T withStackScope(T Function(StackScope scope) f) { final StackPointer stack = stackSave(); final T result = f(StackScope()); + assert( + result is! Future, + 'withStackScope() closure returned a Future. ' + 'The closure passed to withStackScope must be synchronous and must not ' + 'use async/await, because the stack is restored immediately after the ' + 'closure returns.', + ); stackRestore(stack); return result; } diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/skwasm/skwasm_impl/surface.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/skwasm/skwasm_impl/surface.dart index d7870cc3ef3eb..2a6d91bab35f1 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/skwasm/skwasm_impl/surface.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/skwasm/skwasm_impl/surface.dart @@ -172,25 +172,26 @@ class SkwasmSurface implements OffscreenSurface { } @override - Future> rasterizeToImageBitmaps(List pictures) => - withStackScope((StackScope scope) async { - await initialized; - final Pointer pictureHandles = scope - .allocPointerArray(pictures.length) - .cast(); - for (var i = 0; i < pictures.length; i++) { - pictureHandles[i] = (pictures[i] as SkwasmPicture).handle; - } - final int callbackId = surfaceRenderPictures(handle, pictureHandles, pictures.length); - final rasterResult = - (await SkwasmCallbackHandler.instance.registerCallback(callbackId)) as RasterResult; - final RenderResult result = ( - imageBitmaps: rasterResult.imageBitmaps.toDart.cast(), - rasterStartMicros: (rasterResult.rasterStartMilliseconds * 1000).toInt(), - rasterEndMicros: (rasterResult.rasterEndMilliseconds * 1000).toInt(), - ); - return result.imageBitmaps; - }); + Future> rasterizeToImageBitmaps(List pictures) async { + await initialized; + final int callbackId = withStackScope((StackScope scope) { + final Pointer pictureHandles = scope + .allocPointerArray(pictures.length) + .cast(); + for (var i = 0; i < pictures.length; i++) { + pictureHandles[i] = (pictures[i] as SkwasmPicture).handle; + } + return surfaceRenderPictures(handle, pictureHandles, pictures.length); + }); + final rasterResult = + (await SkwasmCallbackHandler.instance.registerCallback(callbackId)) as RasterResult; + final RenderResult result = ( + imageBitmaps: rasterResult.imageBitmaps.toDart.cast(), + rasterStartMicros: (rasterResult.rasterStartMilliseconds * 1000).toInt(), + rasterEndMicros: (rasterResult.rasterEndMilliseconds * 1000).toInt(), + ); + return result.imageBitmaps; + } @override Future recreateContextForCanvas(DomEventTarget newCanvas) async { diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/view_embedder/style_manager.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/view_embedder/style_manager.dart index ba1f0382fcb65..b80b922451b8a 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/view_embedder/style_manager.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/view_embedder/style_manager.dart @@ -107,7 +107,7 @@ void applyGlobalCssRulesToSheet( '}' // Hide outline when the flutter-view root element is focused. '$cssSelectorPrefix:focus {' - ' outline: none;' + ' outline: rgb(0, 0, 0) none 0px;' '}', ); diff --git a/engine/src/flutter/lib/web_ui/test/canvaskit/canvaskit_api_tt_on_test.dart b/engine/src/flutter/lib/web_ui/test/canvaskit/canvaskit_api_tt_on_test.dart index b243734df99ea..b690faa181f65 100644 --- a/engine/src/flutter/lib/web_ui/test/canvaskit/canvaskit_api_tt_on_test.dart +++ b/engine/src/flutter/lib/web_ui/test/canvaskit/canvaskit_api_tt_on_test.dart @@ -5,13 +5,10 @@ import 'package:test/bootstrap/browser.dart'; import 'package:test/test.dart'; import 'package:ui/src/engine.dart'; -import 'package:ui/ui_web/src/ui_web.dart' as ui_web; import '../common/matchers.dart'; import 'canvaskit_api_test.dart'; -final bool isBlink = ui_web.browser.browserEngine == ui_web.BrowserEngine.blink; - const String goodUrl = 'https://www.unpkg.com/blah-blah/33.x/canvaskit.js'; const String badUrl = 'https://www.unpkg.com/soemthing/not-canvaskit.js'; @@ -42,13 +39,13 @@ void testMainWithTTOn() { createTrustedScriptUrl(badUrl); }, throwsAssertionError); }); - }, skip: !isBlink); + }, skip: domWindow.trustedTypes == null); group('Trusted Types API NOT supported', () { test('createTrustedScriptUrl - returns unmodified url', () async { expect(createTrustedScriptUrl(badUrl), badUrl); }); - }, skip: isBlink); + }, skip: domWindow.trustedTypes != null); } /// Enables Trusted Types by setting the appropriate meta tag in the DOM: diff --git a/engine/src/flutter/lib/web_ui/test/engine/view_embedder/style_manager_test.dart b/engine/src/flutter/lib/web_ui/test/engine/view_embedder/style_manager_test.dart index f2f8502750e40..931f8a77b4b1a 100644 --- a/engine/src/flutter/lib/web_ui/test/engine/view_embedder/style_manager_test.dart +++ b/engine/src/flutter/lib/web_ui/test/engine/view_embedder/style_manager_test.dart @@ -5,7 +5,6 @@ import 'package:test/bootstrap/browser.dart'; import 'package:test/test.dart'; import 'package:ui/src/engine.dart'; -import 'package:ui/ui_web/src/ui_web.dart' as ui_web; import '../../common/matchers.dart'; @@ -18,6 +17,9 @@ void doTests() { test('attachGlobalStyles hides the outline when focused', () { final DomElement flutterViewElement = createDomElement(DomManager.flutterViewTagName); + // Set a tab index so that the element is focusable. + flutterViewElement.tabIndex = 0; + domDocument.body!.append(flutterViewElement); StyleManager.attachGlobalStyles( node: flutterViewElement, @@ -25,10 +27,11 @@ void doTests() { styleNonce: 'testing', cssSelectorPrefix: DomManager.flutterViewTagName, ); - final expected = ui_web.browser.browserEngine == ui_web.BrowserEngine.firefox - ? 'rgb(0, 0, 0) 0px' - : 'rgb(0, 0, 0) none 0px'; - final String got = domWindow.getComputedStyle(flutterViewElement, 'focus').outline; + final expected = isFirefox ? 'rgb(0, 0, 0) 0px' : 'rgb(0, 0, 0) none 0px'; + + // Focus the element. + flutterViewElement.focusWithoutScroll(); + final String got = domWindow.getComputedStyle(flutterViewElement).outline; expect(got, expected); }); diff --git a/engine/src/flutter/lib/web_ui/test/felt_config.yaml b/engine/src/flutter/lib/web_ui/test/felt_config.yaml index 77874ac154dbb..f5bfa0583565f 100644 --- a/engine/src/flutter/lib/web_ui/test/felt_config.yaml +++ b/engine/src/flutter/lib/web_ui/test/felt_config.yaml @@ -30,6 +30,10 @@ test-sets: - name: canvaskit directory: canvaskit + # Tests for skwasm-renderer-specific functionality + - name: skwasm + directory: skwasm + # Tests for renderer functionality that can be run on any renderer - name: ui directory: ui @@ -63,6 +67,10 @@ test-bundles: test-set: ui compile-configs: dart2wasm-skwasm + - name: dart2wasm-skwasm-skwasm + test-set: skwasm + compile-configs: dart2wasm-skwasm + - name: fallbacks test-set: fallbacks compile-configs: @@ -145,6 +153,11 @@ test-suites: run-config: chrome-wimp artifact-deps: [ skwasm ] + - name: chrome-dart2wasm-wimp-skwasm + test-bundle: dart2wasm-skwasm-skwasm + run-config: chrome-wimp + artifact-deps: [ skwasm ] + - name: chrome-dart2js-canvaskit-engine test-bundle: dart2js-canvaskit-engine run-config: chrome @@ -258,11 +271,21 @@ test-suites: run-config: chrome-coi artifact-deps: [ skwasm ] + - name: chrome-coi-dart2wasm-skwasm-skwasm + test-bundle: dart2wasm-skwasm-skwasm + run-config: chrome-coi + artifact-deps: [ skwasm ] + - name: chrome-force-st-dart2wasm-skwasm-ui test-bundle: dart2wasm-skwasm-ui run-config: chrome-force-st artifact-deps: [ skwasm ] + - name: chrome-force-st-dart2wasm-skwasm-skwasm + test-bundle: dart2wasm-skwasm-skwasm + run-config: chrome-force-st + artifact-deps: [ skwasm ] + - name: chrome-fallbacks test-bundle: fallbacks run-config: chrome diff --git a/engine/src/flutter/lib/web_ui/test/skwasm/raw_memory_test.dart b/engine/src/flutter/lib/web_ui/test/skwasm/raw_memory_test.dart new file mode 100644 index 0000000000000..56b15b314ea69 --- /dev/null +++ b/engine/src/flutter/lib/web_ui/test/skwasm/raw_memory_test.dart @@ -0,0 +1,37 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:test/bootstrap/browser.dart'; +import 'package:test/test.dart'; +import 'package:ui/src/engine/skwasm/skwasm_impl.dart'; + +void main() { + internalBootstrapBrowserTest(() => testMain); +} + +void testMain() { + group('withStackScope', () { + test('throws AssertionError when closure is async', () { + expect( + () => withStackScope((scope) async { + return 1; + }), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('withStackScope() closure returned a Future'), + ), + ), + ); + }); + + test('works with synchronous closure', () { + final int result = withStackScope((scope) { + return 42; + }); + expect(result, 42); + }); + }); +} diff --git a/engine/src/flutter/lib/web_ui/test/ui/platform_view_position_test.dart b/engine/src/flutter/lib/web_ui/test/ui/platform_view_position_test.dart new file mode 100644 index 0000000000000..f01ff23e11f48 --- /dev/null +++ b/engine/src/flutter/lib/web_ui/test/ui/platform_view_position_test.dart @@ -0,0 +1,74 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:js_interop'; + +import 'package:test/bootstrap/browser.dart'; +import 'package:test/test.dart'; +import 'package:ui/src/engine.dart' as engine; +import 'package:ui/src/engine.dart'; +import 'package:ui/ui.dart' as ui; +import 'package:ui/ui_web/src/ui_web.dart' as ui_web; + +import '../common/rendering.dart'; +import '../common/test_initialization.dart'; +import 'utils.dart'; + +void main() { + internalBootstrapBrowserTest(() => testMain); +} + +Future testMain() async { + setUpUnitTests(withImplicitView: true, setUpTestViewDimensions: false); + + test('Onscreen canvas has position: absolute', () async { + // Force multi-surface mode to ensure OnscreenCanvasProvider is used. + engine.debugOverrideJsConfiguration( + {'canvasKitForceMultiSurfaceRasterizer': true}.jsify() + as engine.JsFlutterConfiguration?, + ); + // Reset the renderer to ensure it is created with the new configuration. + engine.renderer.debugResetRasterizer(); + engine.renderer.debugClear(); + + const platformViewType = 'test-platform-view'; + ui_web.platformViewRegistry.registerViewFactory(platformViewType, (int viewId) { + final DomElement element = createDomHTMLDivElement(); + element.id = 'view-$viewId'; + return element; + }); + + await createPlatformView(0, platformViewType); + + // Create a scene with a platform view and some drawing to trigger canvas creation. + final recorder = ui.PictureRecorder(); + final canvas = ui.Canvas(recorder); + canvas.drawRect( + const ui.Rect.fromLTWH(0, 0, 100, 100), + ui.Paint()..color = const ui.Color(0xFFFF0000), + ); + final ui.Picture picture = recorder.endRecording(); + + final sb = ui.SceneBuilder(); + sb.pushOffset(0, 0); + sb.addPlatformView(0, width: 100, height: 100); + sb.addPicture(ui.Offset.zero, picture); + + await renderScene(sb.build()); + + // Find the canvas element. + final DomElement canvasElement = (implicitView as engine.EngineFlutterView).dom.sceneHost + .querySelectorAll('canvas') + .single; + + // Verify position is absolute. + expect( + canvasElement.style.position, + 'absolute', + reason: 'Canvas should have position: absolute', + ); + + engine.debugOverrideJsConfiguration(null); + }); +} diff --git a/engine/src/flutter/runtime/BUILD.gn b/engine/src/flutter/runtime/BUILD.gn index 2f7aea3d0d107..259ad3b4b697f 100644 --- a/engine/src/flutter/runtime/BUILD.gn +++ b/engine/src/flutter/runtime/BUILD.gn @@ -114,10 +114,15 @@ source_set("runtime") { "//flutter/fml", "//flutter/lib/io", "//flutter/shell/common:display", + "//flutter/shell/common/shorebird:updater", "//flutter/skia", "//flutter/third_party/tonic", "//flutter/txt", ] + + if (is_ios) { + deps += [ "//flutter/runtime/shorebird:patch_cache" ] + } } if (enable_unittests) { diff --git a/engine/src/flutter/runtime/dart_isolate.cc b/engine/src/flutter/runtime/dart_isolate.cc index 521c284f1c905..c81ab7c7bd44d 100644 --- a/engine/src/flutter/runtime/dart_isolate.cc +++ b/engine/src/flutter/runtime/dart_isolate.cc @@ -1108,7 +1108,9 @@ Dart_Isolate DartIsolate::DartIsolateGroupCreateCallback( advisory_script_entrypoint, parent_group_data.GetChildIsolatePreparer(), parent_group_data.GetIsolateCreateCallback(), - parent_group_data.GetIsolateShutdownCallback()))); + parent_group_data.GetIsolateShutdownCallback(), + nullptr // native_assets_manager + ))); TaskRunners null_task_runners(advisory_script_uri, /* platform= */ nullptr, diff --git a/engine/src/flutter/runtime/dart_snapshot.cc b/engine/src/flutter/runtime/dart_snapshot.cc index 198a2e75a7edc..fc70f63a346f2 100644 --- a/engine/src/flutter/runtime/dart_snapshot.cc +++ b/engine/src/flutter/runtime/dart_snapshot.cc @@ -6,6 +6,7 @@ #include +#include #include "flutter/fml/native_library.h" #include "flutter/fml/paths.h" #include "flutter/fml/trace_event.h" @@ -13,6 +14,11 @@ #include "flutter/runtime/dart_vm.h" #include "third_party/dart/runtime/include/dart_api.h" +#if SHOREBIRD_USE_INTERPRETER +#include "flutter/runtime/shorebird/patch_cache.h" // nogncheck +#endif +#include "flutter/shell/common/shorebird/updater.h" // nogncheck + namespace flutter { const char* DartSnapshot::kVMDataSymbol = "kDartVmSnapshotData"; @@ -145,7 +151,20 @@ static std::shared_ptr ResolveIsolateData( nullptr, // release_func true // dontneed_safe ); -#else // DART_SNAPSHOT_STATIC_LINK +#else // DART_SNAPSHOT_STATIC_LINK + // Tell the Rust updater we're booting from whatever patch it selected. + // This copies next_boot โ†’ current_boot in the Rust state. The call is + // guarded inside Updater to execute at most once per process โ€” see the + // Updater class comment for why this matters in add-to-app and + // FlutterEngineGroup scenarios. + shorebird::Updater::Instance().ReportLaunchStart(); +#if SHOREBIRD_USE_INTERPRETER + // Try loading from a Shorebird patch first. + if (auto mapping = TryLoadFromPatch(settings.application_library_paths, + DartSnapshot::kIsolateDataSymbol)) { + return mapping; + } +#endif // SHOREBIRD_USE_INTERPRETER return SearchMapping( settings.isolate_snapshot_data, // embedder_mapping_callback settings.isolate_snapshot_data_path, // file_path @@ -165,7 +184,15 @@ static std::shared_ptr ResolveIsolateInstructions( nullptr, // release_func true // dontneed_safe ); -#else // DART_SNAPSHOT_STATIC_LINK +#else // DART_SNAPSHOT_STATIC_LINK +#if SHOREBIRD_USE_INTERPRETER + // Try loading from a Shorebird patch first. + if (auto mapping = + TryLoadFromPatch(settings.application_library_paths, + DartSnapshot::kIsolateInstructionsSymbol)) { + return mapping; + } +#endif // SHOREBIRD_USE_INTERPRETER return SearchMapping( settings.isolate_snapshot_instr, // embedder_mapping_callback settings.isolate_snapshot_instr_path, // file_path diff --git a/engine/src/flutter/runtime/shorebird/BUILD.gn b/engine/src/flutter/runtime/shorebird/BUILD.gn new file mode 100644 index 0000000000000..1f856e36d3f48 --- /dev/null +++ b/engine/src/flutter/runtime/shorebird/BUILD.gn @@ -0,0 +1,16 @@ +import("//flutter/common/config.gni") + +source_set("patch_cache") { + sources = [ + "patch_cache.cc", + "patch_cache.h", + "patch_mapping.cc", + "patch_mapping.h", + ] + + deps = [ + "//flutter/fml", + "//flutter/runtime:libdart", + "//flutter/shell/common/shorebird:updater", + ] +} diff --git a/engine/src/flutter/runtime/shorebird/patch_cache.cc b/engine/src/flutter/runtime/shorebird/patch_cache.cc new file mode 100644 index 0000000000000..8c5cc3a83f182 --- /dev/null +++ b/engine/src/flutter/runtime/shorebird/patch_cache.cc @@ -0,0 +1,165 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "flutter/runtime/shorebird/patch_cache.h" + +#include + +#include "flutter/fml/logging.h" +#include "flutter/fml/mapping.h" +#include "flutter/runtime/shorebird/patch_mapping.h" +#include "third_party/dart/runtime/include/dart_api.h" + +namespace flutter { + +namespace { + +// These symbol names match the constants in dart_snapshot.cc. +// We duplicate them here rather than extracting them into a header. +// They are actually defined down in Dart and will never change. +constexpr const char* kIsolateDataSymbol = "kDartIsolateSnapshotData"; +constexpr const char* kIsolateInstructionsSymbol = + "kDartIsolateSnapshotInstructions"; + +} // namespace + +// PatchCacheEntry implementation + +std::shared_ptr PatchCacheEntry::Create( + const std::string& path) { + // vmcode files currently use ELF internally after a prefix of a Shorebird + // linker header. + auto elf_mapping = fml::FileMapping::CreateReadOnly(path); + if (!elf_mapping) { + FML_LOG(ERROR) << "Failed to map file: " << path; + return nullptr; + } + + int elf_file_offset = Shorebird_ReadLinkHeader(elf_mapping->GetMapping(), + elf_mapping->GetSize()); + + const char* error = nullptr; + // The VM Snapshot is identical for all binaries produced by a given version + // of Dart. Our linker checks this and will fail to link if ever the VM + // snapshot changes. We ignore the VM data/instrs here. + const uint8_t* ignored_vm_data = nullptr; + const uint8_t* ignored_vm_instrs = nullptr; + const uint8_t* isolate_data = nullptr; + const uint8_t* isolate_instrs = nullptr; + + Dart_LoadedElf* elf = Dart_LoadELF( + path.c_str(), elf_file_offset, &error, &ignored_vm_data, + &ignored_vm_instrs, &isolate_data, &isolate_instrs, dart::bin::kReadOnly); + + if (elf == nullptr) { + FML_LOG(ERROR) << "Failed to load patch at " << path << " error: " << error; + return nullptr; + } + + FML_LOG(INFO) << "Loaded patch from " << path; + + return std::shared_ptr( + new PatchCacheEntry(path, elf, isolate_data, isolate_instrs)); +} + +PatchCacheEntry::PatchCacheEntry(const std::string& path, + Dart_LoadedElf* elf, + const uint8_t* isolate_data, + const uint8_t* isolate_instrs) + : path_(path), + elf_(elf), + isolate_data_(isolate_data), + isolate_instrs_(isolate_instrs) {} + +PatchCacheEntry::~PatchCacheEntry() { + if (elf_ != nullptr) { + FML_LOG(INFO) << "Unloading patch from " << path_; + Dart_UnloadELF(elf_); + elf_ = nullptr; + } +} + +PatchCache& PatchCache::Instance() { + static PatchCache instance; + return instance; +} + +std::shared_ptr PatchCache::GetOrLoad( + const std::string& path) { + std::lock_guard lock(mutex_); + + // Check if we have a cached entry that's still alive + auto it = cache_.find(path); + if (it != cache_.end()) { + if (auto entry = it->second.lock()) { + FML_LOG(INFO) << "PatchCache hit for " << path; + return entry; + } + // Entry expired, remove it + cache_.erase(it); + } + + // Load a new entry + auto entry = PatchCacheEntry::Create(path); + if (entry) { + cache_[path] = entry; // Store weak_ptr + } + + return entry; +} + +void PatchCache::PruneExpired() { + std::lock_guard lock(mutex_); + + for (auto it = cache_.begin(); it != cache_.end();) { + if (it->second.expired()) { + it = cache_.erase(it); + } else { + ++it; + } + } +} + +std::shared_ptr TryLoadFromPatch( + const std::vector& native_library_paths, + const char* symbol_name) { + if (native_library_paths.empty()) { + return nullptr; + } + + // Check if the first path is a Shorebird patch (.vmcode file) + const auto& patch_path = native_library_paths.front(); + bool is_patch = patch_path.find(".vmcode") != std::string::npos; + if (!is_patch) { + return nullptr; + } + + // Patches only contain isolate data/instructions, not VM data/instructions. + // Return nullptr for VM symbols to allow fallback to the base app. + std::string symbol(symbol_name); + if (symbol != kIsolateDataSymbol && symbol != kIsolateInstructionsSymbol) { + return nullptr; + } + + // Load the patch using the cache. + auto cache_entry = PatchCache::Instance().GetOrLoad(patch_path); + if (!cache_entry) { + FML_LOG(FATAL) << "Failed to load symbol from patch at " << patch_path; + return nullptr; + } + + FML_LOG(INFO) << "Loading symbol from patch: " << symbol_name; + + // ReportLaunchStart is now called from ResolveIsolateData in + // dart_snapshot.cc, which runs before TryLoadFromPatch on all platforms. + + if (symbol == kIsolateDataSymbol) { + return PatchMapping::CreateIsolateData(cache_entry); + } else { + FML_CHECK(symbol == kIsolateInstructionsSymbol); + return PatchMapping::CreateIsolateInstructions(cache_entry); + } +} + +} // namespace flutter diff --git a/engine/src/flutter/runtime/shorebird/patch_cache.h b/engine/src/flutter/runtime/shorebird/patch_cache.h new file mode 100644 index 0000000000000..1f2e14fab711d --- /dev/null +++ b/engine/src/flutter/runtime/shorebird/patch_cache.h @@ -0,0 +1,104 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef FLUTTER_RUNTIME_SHOREBIRD_PATCH_CACHE_H_ +#define FLUTTER_RUNTIME_SHOREBIRD_PATCH_CACHE_H_ + +#include +#include +#include +#include +#include + +#include + +#include "flutter/fml/macros.h" +#include "flutter/fml/mapping.h" + +namespace flutter { + +/// A cache entry that holds a loaded patch file and its extracted snapshot +/// pointers. The patch is automatically unloaded when the last reference to +/// this entry is released. +class PatchCacheEntry { + public: + /// Creates a new cache entry by loading the patch file at the given path. + /// Returns nullptr if loading fails. + static std::shared_ptr Create(const std::string& path); + + ~PatchCacheEntry(); + + /// Returns the isolate snapshot data pointer. + const uint8_t* isolate_data() const { return isolate_data_; } + + /// Returns the isolate snapshot instructions pointer. + const uint8_t* isolate_instructions() const { return isolate_instrs_; } + + /// Returns the path this entry was loaded from. + const std::string& path() const { return path_; } + + private: + PatchCacheEntry(const std::string& path, + Dart_LoadedElf* elf, + const uint8_t* isolate_data, + const uint8_t* isolate_instrs); + + std::string path_; + Dart_LoadedElf* elf_; + const uint8_t* isolate_data_; + const uint8_t* isolate_instrs_; + + FML_DISALLOW_COPY_AND_ASSIGN(PatchCacheEntry); +}; + +/// A thread-safe cache for loaded patch files. Cache entries are automatically +/// removed when all references to them are released. +class PatchCache { + public: + /// Returns the singleton instance of the cache. + static PatchCache& Instance(); + + /// Gets or loads a patch file at the given path. If the file is already + /// cached and the entry is still alive, returns the existing entry. + /// Otherwise, loads the file and creates a new cache entry. + /// Returns nullptr if loading fails. + std::shared_ptr GetOrLoad(const std::string& path); + + /// Removes expired entries from the cache. This is called automatically + /// by GetOrLoad, but can also be called explicitly. + void PruneExpired(); + + private: + PatchCache() = default; + ~PatchCache() = default; + + std::mutex mutex_; + // We store weak references so entries are automatically cleaned up when + // all ElfMapping instances release their references. + std::map> cache_; + + FML_DISALLOW_COPY_AND_ASSIGN(PatchCache); +}; + +/// Checks if the first path in native_library_paths is a Shorebird patch +/// (.vmcode file) and if so, attempts to load the requested symbol from +/// the patch. +/// +/// @param native_library_paths The list of library paths to check. The first +/// path is checked for the .vmcode extension. +/// @param symbol_name The symbol to load (kIsolateDataSymbol or +/// kIsolateInstructionsSymbol). +/// @return A mapping for the requested symbol if this is a patch and the +/// symbol is available in the patch, nullptr otherwise. +/// +/// Note: Patches only contain isolate data/instructions, not VM +/// data/instructions. For VM symbols, this will always return nullptr, +/// allowing the caller to fall back to loading from the base app. +std::shared_ptr TryLoadFromPatch( + const std::vector& native_library_paths, + const char* symbol_name); + +} // namespace flutter + +#endif // FLUTTER_RUNTIME_SHOREBIRD_PATCH_CACHE_H_ diff --git a/engine/src/flutter/runtime/shorebird/patch_mapping.cc b/engine/src/flutter/runtime/shorebird/patch_mapping.cc new file mode 100644 index 0000000000000..d444cda817c34 --- /dev/null +++ b/engine/src/flutter/runtime/shorebird/patch_mapping.cc @@ -0,0 +1,51 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "flutter/runtime/shorebird/patch_mapping.h" + +#include "third_party/dart/runtime/include/dart_native_api.h" + +namespace flutter { + +std::shared_ptr PatchMapping::CreateIsolateData( + std::shared_ptr entry) { + if (!entry) { + return nullptr; + } + const uint8_t* data = entry->isolate_data(); + size_t size = Dart_SnapshotDataSize(data); + return std::shared_ptr(new PatchMapping(entry, data, size)); +} + +std::shared_ptr PatchMapping::CreateIsolateInstructions( + std::shared_ptr entry) { + if (!entry) { + return nullptr; + } + const uint8_t* data = entry->isolate_instructions(); + size_t size = Dart_SnapshotInstrSize(data); + return std::shared_ptr(new PatchMapping(entry, data, size)); +} + +PatchMapping::PatchMapping(std::shared_ptr entry, + const uint8_t* data, + size_t size) + : cache_entry_(std::move(entry)), data_(data), size_(size) {} + +PatchMapping::~PatchMapping() = default; + +size_t PatchMapping::GetSize() const { + return size_; +} + +const uint8_t* PatchMapping::GetMapping() const { + return data_; +} + +bool PatchMapping::IsDontNeedSafe() const { + // Patch mappings are file-backed and safe for madvise(DONTNEED). + return true; +} + +} // namespace flutter diff --git a/engine/src/flutter/runtime/shorebird/patch_mapping.h b/engine/src/flutter/runtime/shorebird/patch_mapping.h new file mode 100644 index 0000000000000..8c2e494a9004c --- /dev/null +++ b/engine/src/flutter/runtime/shorebird/patch_mapping.h @@ -0,0 +1,55 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef FLUTTER_RUNTIME_SHOREBIRD_PATCH_MAPPING_H_ +#define FLUTTER_RUNTIME_SHOREBIRD_PATCH_MAPPING_H_ + +#include + +#include "flutter/fml/macros.h" +#include "flutter/fml/mapping.h" +#include "flutter/runtime/shorebird/patch_cache.h" + +namespace flutter { + +/// A Mapping implementation that references data from a cached patch file. +/// Holding a reference to this mapping keeps the underlying patch loaded. +class PatchMapping final : public fml::Mapping { + public: + /// Creates a mapping for the isolate snapshot data from the given cache + /// entry. + static std::shared_ptr CreateIsolateData( + std::shared_ptr entry); + + /// Creates a mapping for the isolate snapshot instructions from the given + /// cache entry. + static std::shared_ptr CreateIsolateInstructions( + std::shared_ptr entry); + + ~PatchMapping() override; + + // |fml::Mapping| + size_t GetSize() const override; + + // |fml::Mapping| + const uint8_t* GetMapping() const override; + + // |fml::Mapping| + bool IsDontNeedSafe() const override; + + private: + PatchMapping(std::shared_ptr entry, + const uint8_t* data, + size_t size); + + std::shared_ptr cache_entry_; + const uint8_t* data_; + size_t size_; + + FML_DISALLOW_COPY_AND_ASSIGN(PatchMapping); +}; + +} // namespace flutter + +#endif // FLUTTER_RUNTIME_SHOREBIRD_PATCH_MAPPING_H_ diff --git a/engine/src/flutter/shell/common/BUILD.gn b/engine/src/flutter/shell/common/BUILD.gn index e1aba738f17be..5eb7b979fe2f0 100644 --- a/engine/src/flutter/shell/common/BUILD.gn +++ b/engine/src/flutter/shell/common/BUILD.gn @@ -153,6 +153,7 @@ source_set("common") { "//flutter/lib/ui", "//flutter/runtime", "//flutter/shell/common:base64", + "//flutter/shell/common/shorebird:updater", "//flutter/shell/geometry", "//flutter/shell/profiling", "//flutter/skia", @@ -342,6 +343,7 @@ if (enable_unittests) { "//flutter/common/graphics", "//flutter/display_list/testing:display_list_testing", "//flutter/shell/common:base64", + "//flutter/shell/common/shorebird:updater", "//flutter/shell/profiling:profiling_unittests", "//flutter/shell/version", "//flutter/testing:fixture_test", diff --git a/engine/src/flutter/shell/common/fixtures/shelltest_screenshot.png b/engine/src/flutter/shell/common/fixtures/shelltest_screenshot.png index 26f5b38f102b4..bae9aad0fef9b 100644 Binary files a/engine/src/flutter/shell/common/fixtures/shelltest_screenshot.png and b/engine/src/flutter/shell/common/fixtures/shelltest_screenshot.png differ diff --git a/engine/src/flutter/shell/common/shell.cc b/engine/src/flutter/shell/common/shell.cc index 762663b061d1c..87b5d9094f76b 100644 --- a/engine/src/flutter/shell/common/shell.cc +++ b/engine/src/flutter/shell/common/shell.cc @@ -47,6 +47,8 @@ #include "third_party/skia/include/core/SkGraphics.h" #include "third_party/tonic/common/log.h" +#include "flutter/shell/common/shorebird/updater.h" + namespace flutter { constexpr char kSkiaChannel[] = "flutter/skia"; @@ -522,6 +524,18 @@ Shell::Shell(DartVMRef vm, is_gpu_disabled_sync_switch_(new fml::SyncSwitch(is_gpu_disabled)), weak_factory_gpu_(nullptr), weak_factory_(this) { + // Report launch outcome to the Shorebird updater for crash recovery. + // If the VM failed to start, we report failure so the updater can roll + // back the patch. These calls are guarded inside Updater to execute at + // most once per process โ€” only the first Shell's outcome is reported. + // In add-to-app, subsequent engines are silently ignored since they + // boot from the same snapshot that was already reported on. + // On unsupported platforms, NoOpUpdater handles these calls gracefully. + if (!vm_) { + shorebird::Updater::Instance().ReportLaunchFailure(); + } else { + shorebird::Updater::Instance().ReportLaunchSuccess(); + } FML_CHECK(!settings.enable_software_rendering || !settings.enable_impeller) << "Software rendering is incompatible with Impeller."; if (!settings.enable_impeller && settings.warn_on_impeller_opt_out) { diff --git a/engine/src/flutter/shell/common/shell_unittests.cc b/engine/src/flutter/shell/common/shell_unittests.cc index 2ce1a9040d5a4..5ed39de31b6be 100644 --- a/engine/src/flutter/shell/common/shell_unittests.cc +++ b/engine/src/flutter/shell/common/shell_unittests.cc @@ -52,6 +52,8 @@ #include "third_party/skia/include/codec/SkCodecAnimation.h" #include "third_party/tonic/converter/dart_converter.h" +#include "flutter/shell/common/shorebird/updater.h" + #ifdef SHELL_ENABLE_VULKAN #include "flutter/vulkan/vulkan_application.h" // nogncheck #endif @@ -5108,6 +5110,70 @@ TEST_F(ShellTest, ShoulDiscardLayerTreeIfFrameIsSizedIncorrectly) { DestroyShell(std::move(shell), task_runners); } +// Test the full boot flow: ReportLaunchStart is called from +// ResolveIsolateData, then ReportLaunchSuccess from the Shell constructor. +// Both are guarded to run at most once per process. +TEST_F(ShellTest, ShorebirdBootFlowCallsLaunchStartThenSuccess) { + auto mock = std::make_unique(); + auto* mock_ptr = mock.get(); + shorebird::Updater::SetInstanceForTesting(std::move(mock)); + shorebird::Updater::ResetLaunchStateForTesting(); + + auto settings = CreateSettingsForFixture(); + auto task_runners = GetTaskRunnersForFixture(); + auto shell = CreateShell(settings, task_runners); + ASSERT_TRUE(shell); + + const auto& log = mock_ptr->call_log(); + ASSERT_EQ(log.size(), 2u); + EXPECT_EQ(log[0], "ReportLaunchStart"); + EXPECT_EQ(log[1], "ReportLaunchSuccess"); + + DestroyShell(std::move(shell), task_runners); + shorebird::Updater::ResetLaunchStateForTesting(); + shorebird::Updater::ResetInstanceForTesting(); +} + +// In add-to-app, multiple engines may be created within a single process. +// Only the first engine should report launch start/success to the Rust +// updater. This prevents the updater from promoting a newly-downloaded patch +// to "current_boot" when subsequent engines are still running the original +// snapshot that was selected at process init time. +TEST_F(ShellTest, ShorebirdUpdaterReportsOnlyOnceForMultipleShells) { + auto mock = std::make_unique(); + auto* mock_ptr = mock.get(); + shorebird::Updater::SetInstanceForTesting(std::move(mock)); + shorebird::Updater::ResetLaunchStateForTesting(); + + auto settings = CreateSettingsForFixture(); + + // Create first shell โ€” gets Start + Success + auto task_runners1 = GetTaskRunnersForFixture(); + auto shell1 = CreateShell(settings, task_runners1); + ASSERT_TRUE(shell1); + EXPECT_EQ(mock_ptr->launch_start_count(), 1); + EXPECT_EQ(mock_ptr->launch_success_count(), 1); + + // Create second shell โ€” guarded, no additional Start or Success calls. + auto task_runners2 = GetTaskRunnersForFixture(); + auto shell2 = CreateShell(settings, task_runners2); + ASSERT_TRUE(shell2); + EXPECT_EQ(mock_ptr->launch_start_count(), 1); + EXPECT_EQ(mock_ptr->launch_success_count(), 1); + + // Only one Start+Success pair in the call log. + const auto& log = mock_ptr->call_log(); + ASSERT_EQ(log.size(), 2u); + EXPECT_EQ(log[0], "ReportLaunchStart"); + EXPECT_EQ(log[1], "ReportLaunchSuccess"); + + DestroyShell(std::move(shell1), task_runners1); + DestroyShell(std::move(shell2), task_runners2); + + shorebird::Updater::ResetLaunchStateForTesting(); + shorebird::Updater::ResetInstanceForTesting(); +} + } // namespace testing } // namespace flutter diff --git a/engine/src/flutter/shell/common/shorebird/BUILD.gn b/engine/src/flutter/shell/common/shorebird/BUILD.gn new file mode 100644 index 0000000000000..22b11a614cad1 --- /dev/null +++ b/engine/src/flutter/shell/common/shorebird/BUILD.gn @@ -0,0 +1,162 @@ +import("//flutter/common/config.gni") +import("//flutter/shell/common/shorebird/build_rust_updater.gni") +import("//flutter/testing/testing.gni") + +if (is_ios || is_mac) { + import("//build/config/darwin/darwin_sdk.gni") +} + +source_set("snapshots_data_handle") { + sources = [ + "snapshots_data_handle.cc", + "snapshots_data_handle.h", + ] + + deps = [ + "//flutter/fml", + "//flutter/runtime", + "//flutter/runtime:libdart", + "//flutter/shell/common", + ] +} + +if (shorebird_updater_supported) { + action("build_rust_updater") { + script = "//flutter/shell/common/shorebird/build_rust_updater.py" + + # The stamp file gives Ninja a deterministic output to depend on, but + # we also have to declare the actual static library so consumers + # referencing it via `libs = [ shorebird_updater_output_lib ]` find a + # rule that produces it. Without the lib in `outputs`, Ninja errors + # with `'cargo_target/.../libupdater.a' missing and no known rule to + # make it` once the cargo target dir lives inside root_out_dir (#124). + _stamp = + "$target_gen_dir/rust_updater_${shorebird_updater_rust_target}.stamp" + outputs = [ + _stamp, + shorebird_updater_output_lib, + ] + + args = [ + "--rust-target", + shorebird_updater_rust_target, + "--manifest-dir", + rebase_path("$shorebird_updater_dir", root_build_dir), + "--target-dir", + rebase_path("$shorebird_updater_target_dir", root_build_dir), + "--output-lib", + rebase_path("$shorebird_updater_output_lib", root_build_dir), + "--stamp", + rebase_path(_stamp, root_build_dir), + ] + + if (is_android) { + args += [ + "--ndk-path", + rebase_path(android_ndk_root, root_build_dir), + "--android-api-level", + "$android_api_level", + ] + } + + if (is_ios) { + # Thread the engine's iOS deployment target into the cargo build so + # the cc crate (compiling C deps like zstd-sys) and rustc's cdylib + # link step both target the same iOS version as the C++ engine. + # Without this, cargo defaults to whatever rustc/cc bake in (rustc's + # built-in target spec for aarch64-apple-ios is iOS 10/11, while cc + # picks up the host SDK), which causes link warnings and unresolved + # symbols like ___chkstk_darwin. + args += [ + "--ios-deployment-target", + ios_deployment_target, + ] + } + + if (is_mac) { + args += [ + "--mac-deployment-target", + mac_deployment_target, + ] + } + + # Declare inputs so Ninja knows when to re-run the action. + inputs = [ + "$shorebird_updater_dir/Cargo.toml", + "$shorebird_updater_dir/Cargo.lock", + "$shorebird_updater_dir/library/Cargo.toml", + "$shorebird_updater_dir/library/build.rs", + "$shorebird_updater_dir/library/cbindgen.toml", + "$shorebird_updater_dir/library/.cargo/config.toml", + ] + inputs += shorebird_updater_rs_sources + } +} + +# C++ wrapper around the Rust updater C API. +# This provides a testable abstraction layer that can be mocked for testing. +source_set("updater") { + sources = [ + "updater.cc", + "updater.h", + ] + + deps = [ "//flutter/fml" ] + + # For the Rust updater C API (shorebird_report_launch_start, etc.) + include_dirs = [ "//flutter" ] + + if (shorebird_updater_supported) { + deps += [ ":build_rust_updater" ] + libs = [ shorebird_updater_output_lib ] + + if (is_win) { + libs += [ "userenv.lib" ] + } + } +} + +source_set("shorebird") { + sources = [ + "shorebird.cc", + "shorebird.h", + ] + + deps = [ + ":snapshots_data_handle", + ":updater", + "//flutter/fml", + "//flutter/runtime", + "//flutter/runtime:libdart", + "//flutter/shell/common", + "//flutter/shell/platform/embedder:embedder_headers", + ] +} + +if (enable_unittests) { + test_fixtures("shorebird_fixtures") { + fixtures = [] + } + + executable("shorebird_unittests") { + testonly = true + + sources = [ + "patch_cache_unittests.cc", + "shorebird_unittests.cc", + "snapshots_data_handle_unittests.cc", + "updater_unittests.cc", + ] + + deps = [ + ":shorebird", + ":shorebird_fixtures", + ":snapshots_data_handle", + ":updater", + "//flutter/runtime", + "//flutter/runtime/shorebird:patch_cache", + "//flutter/testing", + "//flutter/testing:fixture_test", + ] + } +} diff --git a/engine/src/flutter/shell/common/shorebird/build_rust_updater.gni b/engine/src/flutter/shell/common/shorebird/build_rust_updater.gni new file mode 100644 index 0000000000000..08b72424af7b6 --- /dev/null +++ b/engine/src/flutter/shell/common/shorebird/build_rust_updater.gni @@ -0,0 +1,86 @@ +# Copyright 2024 The Shorebird Authors. All rights reserved. +# Use of this source code is governed by a MIT-style license that can be +# found in the LICENSE file. + +# Computes variables needed to build and link the Rust updater library. +# +# Exported variables: +# shorebird_updater_supported - Whether the current platform is supported. +# shorebird_updater_output_lib - Path to the built static library. +# shorebird_updater_rust_target - Rust target triple for the current platform. +# shorebird_updater_rs_sources - List of .rs source files (for GN inputs). + +if (is_android) { + import("//build/config/android/config.gni") +} + +shorebird_updater_dir = "//flutter/third_party/updater" + +# Compute the Rust target triple from the GN target_os/target_cpu. GN +# evaluates BUILD files under every toolchain in use, not just the +# top-level one, so we get called for host/sub toolchains too. Any +# (os, cpu) combo we don't explicitly handle leaves the rust target +# empty and marks the updater unsupported for that toolchain, which is +# the correct behavior โ€” the updater only needs to be built for +# toolchains whose final binary is the engine. +shorebird_updater_rust_target = "" + +if (is_android) { + if (target_cpu == "arm") { + shorebird_updater_rust_target = "armv7-linux-androideabi" + } else if (target_cpu == "arm64") { + shorebird_updater_rust_target = "aarch64-linux-android" + } else if (target_cpu == "x64") { + shorebird_updater_rust_target = "x86_64-linux-android" + } else if (target_cpu == "x86") { + shorebird_updater_rust_target = "i686-linux-android" + } +} else if (is_ios) { + if (target_cpu == "arm64") { + shorebird_updater_rust_target = "aarch64-apple-ios" + } else if (target_cpu == "x64") { + shorebird_updater_rust_target = "x86_64-apple-ios" + } +} else if (is_mac) { + if (target_cpu == "arm64") { + shorebird_updater_rust_target = "aarch64-apple-darwin" + } else if (target_cpu == "x64") { + shorebird_updater_rust_target = "x86_64-apple-darwin" + } +} else if (is_win) { + if (target_cpu == "x64") { + shorebird_updater_rust_target = "x86_64-pc-windows-msvc" + } +} else if (is_linux) { + if (target_cpu == "x64") { + shorebird_updater_rust_target = "x86_64-unknown-linux-gnu" + } +} + +shorebird_updater_supported = shorebird_updater_rust_target != "" + +if (shorebird_updater_supported) { + if (is_win) { + _updater_lib_name = "updater.lib" + } else { + _updater_lib_name = "libupdater.a" + } + + # Cargo target dir lives inside the GN build output dir, not inside the + # source tree. This means: + # - `rm -rf out/` clobbers compiled rust artifacts the same + # way it clobbers compiled C++ artifacts; no special-case cleanup + # in checkout.sh. + # - Each GN config (debug/release/ios/android/etc.) gets its own + # cargo target dir, so they don't step on each other's rlibs. + # - The source checkout stays clean, which is what GN/ninja expects. + shorebird_updater_target_dir = "$root_out_dir/cargo_target" + shorebird_updater_output_lib = "$shorebird_updater_target_dir/$shorebird_updater_rust_target/release/$_updater_lib_name" + + # Glob all .rs source files so the input list stays in sync automatically. + shorebird_updater_rs_sources = + exec_script("//flutter/shell/common/shorebird/list_rust_files.py", + [ rebase_path("$shorebird_updater_dir/library/src", + root_build_dir) ], + "list lines") +} diff --git a/engine/src/flutter/shell/common/shorebird/build_rust_updater.py b/engine/src/flutter/shell/common/shorebird/build_rust_updater.py new file mode 100644 index 0000000000000..aa4d66fe3aed3 --- /dev/null +++ b/engine/src/flutter/shell/common/shorebird/build_rust_updater.py @@ -0,0 +1,183 @@ +#!/usr/bin/env python3 +# Copyright 2024 The Shorebird Authors. All rights reserved. +# Use of this source code is governed by a MIT-style license that can be +# found in the LICENSE file. + +"""Build the Rust updater library via cargo, invoked as a GN action.""" + +import argparse +import os +import subprocess +import sys + + +def main(): + parser = argparse.ArgumentParser(description='Build the Rust updater static library.') + parser.add_argument( + '--rust-target', required=True, help='Rust target triple (e.g. aarch64-linux-android)' + ) + parser.add_argument( + '--manifest-dir', required=True, help='Directory containing the workspace Cargo.toml' + ) + parser.add_argument( + '--target-dir', + required=True, + help='Cargo target directory; should live inside the GN build output dir', + ) + parser.add_argument('--output-lib', required=True, help='Expected output library path') + parser.add_argument('--stamp', required=True, help='Stamp file to write on success') + parser.add_argument('--ndk-path', help='Path to the Android NDK (required for Android targets)') + parser.add_argument( + '--android-api-level', type=int, help='Android API level (required for Android targets)' + ) + parser.add_argument( + '--ios-deployment-target', + help='iOS deployment target (e.g. 13.0); required for *-apple-ios targets', + ) + parser.add_argument( + '--mac-deployment-target', + help='macOS deployment target (e.g. 10.15); required for *-apple-darwin targets', + ) + args = parser.parse_args() + + env = os.environ.copy() + + is_android = 'android' in args.rust_target + is_apple_ios = 'apple-ios' in args.rust_target + is_apple_darwin = 'apple-darwin' in args.rust_target + is_msvc = 'pc-windows-msvc' in args.rust_target + + if is_android: + if not args.ndk_path or not args.android_api_level: + print( + 'ERROR: --ndk-path and --android-api-level are required for ' + 'Android targets.', + file=sys.stderr + ) + return 1 + _configure_android_env(env, args.rust_target, args.ndk_path, args.android_api_level) + + if is_apple_ios: + if not args.ios_deployment_target: + print( + 'ERROR: --ios-deployment-target is required for *-apple-ios targets.', + file=sys.stderr, + ) + return 1 + # Setting IPHONEOS_DEPLOYMENT_TARGET makes both the cc crate (compiling + # transitive C deps like zstd-sys) and rustc's cdylib link step honor + # the engine's iOS deployment target instead of falling back to their + # respective defaults (host SDK for cc, target-spec default for rustc). + env['IPHONEOS_DEPLOYMENT_TARGET'] = args.ios_deployment_target + + if is_apple_darwin: + if not args.mac_deployment_target: + print( + 'ERROR: --mac-deployment-target is required for *-apple-darwin targets.', + file=sys.stderr, + ) + return 1 + env['MACOSX_DEPLOYMENT_TARGET'] = args.mac_deployment_target + + if is_msvc: + _configure_msvc_env(env, args.rust_target) + + # GN passes paths relative to the build output dir (which is cwd when + # Ninja runs the action). Resolve them to absolute paths so they work + # regardless of cargo's working directory. + manifest_path = os.path.abspath(os.path.join(args.manifest_dir, 'Cargo.toml')) + target_dir = os.path.abspath(args.target_dir) + output_lib = os.path.abspath(args.output_lib) + + cmd = [ + 'cargo', + 'build', + '--release', + '--target', + args.rust_target, + '--manifest-path', + manifest_path, + '--target-dir', + target_dir, + '-p', + 'updater', + ] + + print(f'Running: {" ".join(cmd)}', flush=True) + result = subprocess.run(cmd, env=env) + if result.returncode != 0: + print(f'ERROR: cargo build failed with exit code {result.returncode}', file=sys.stderr) + return result.returncode + + if not os.path.exists(output_lib): + print(f'ERROR: Expected output library not found: {output_lib}', file=sys.stderr) + return 1 + + # Write stamp file to signal success to Ninja. + with open(args.stamp, 'w') as f: + f.write('') + + return 0 + + +def _configure_msvc_env(env, rust_target): + """Force the cc crate to compile transitive C deps with the static CRT. + + The engine's Windows build uses the static CRT (/MT). The updater's + .cargo/config.toml sets `-C target-feature=+crt-static` so the rlib is + compiled to expect static-CRT linkage. However, cargo populates + CARGO_CFG_TARGET_FEATURE from the rustc target spec's default features, + not from user rustflags, so +crt-static is invisible to build scripts. + The cc crate (used by transitive *-sys deps like zstd-sys to compile + their C sources) therefore falls back to /MD, producing .obj files + full of __imp_* references that the engine's /MT link cannot resolve + (e.g. __imp_clock, __imp__wassert, __imp_qsort_s). + + Force /MT via the per-target CFLAGS env that the cc crate honors. cc + appends these flags at the end of its command line, so cl.exe sees + `/MD ... /MT` and emits warning D9025 ("overriding '/MD' with '/MT'") + and uses the last one -- /MT wins. + """ + triple_env = rust_target.replace('-', '_') + env[f'CFLAGS_{triple_env}'] = '/MT' + env[f'CXXFLAGS_{triple_env}'] = '/MT' + + +def _configure_android_env(env, rust_target, ndk_path, api_level): + """Set environment variables so cargo can cross-compile for Android.""" + # GN passes paths relative to the build output dir. Resolve to absolute + # so that cargo and the cc crate can find the NDK tools. + ndk_path = os.path.abspath(ndk_path) + + # Determine the host platform tag for NDK toolchain paths. + if sys.platform.startswith('linux'): + host_tag = 'linux-x86_64' + elif sys.platform == 'darwin': + host_tag = 'darwin-x86_64' + elif sys.platform == 'win32': + host_tag = 'windows-x86_64' + else: + raise RuntimeError(f'Unsupported host platform: {sys.platform}') + + toolchain_bin = os.path.join(ndk_path, 'toolchains', 'llvm', 'prebuilt', host_tag, 'bin') + + # The NDK clang binary uses a slightly different prefix for armv7. + # Rust target: armv7-linux-androideabi + # NDK prefix: armv7a-linux-androideabi + clang_prefix = rust_target + if rust_target.startswith('armv7-'): + clang_prefix = 'armv7a-' + rust_target[len('armv7-'):] + + clang = os.path.join(toolchain_bin, f'{clang_prefix}{api_level}-clang') + ar = os.path.join(toolchain_bin, 'llvm-ar') + + # Cargo looks for CARGO_TARGET__LINKER where the triple is + # upper-cased with hyphens replaced by underscores. + triple_env = rust_target.upper().replace('-', '_') + env[f'CARGO_TARGET_{triple_env}_LINKER'] = clang + env['CC'] = clang + env['AR'] = ar + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/engine/src/flutter/shell/common/shorebird/list_rust_files.py b/engine/src/flutter/shell/common/shorebird/list_rust_files.py new file mode 100644 index 0000000000000..f478d7bd8e176 --- /dev/null +++ b/engine/src/flutter/shell/common/shorebird/list_rust_files.py @@ -0,0 +1,28 @@ +#!/usr/bin/env python3 +# Copyright 2024 The Shorebird Authors. All rights reserved. +# Use of this source code is governed by a MIT-style license that can be +# found in the LICENSE file. + +"""List Rust source files for GN input tracking. + +Walks a directory tree and prints all .rs files as absolute paths. Used by +GN's exec_script to generate an input list for the Rust updater build action. + +Usage: + python3 list_rust_files.py +""" + +import os +import sys + + +def main(): + directory = os.path.abspath(sys.argv[1]) + for root, _, files in os.walk(directory): + for filename in sorted(files): + if filename.endswith('.rs'): + print(os.path.join(root, filename).replace(os.sep, '/')) + + +if __name__ == '__main__': + main() diff --git a/engine/src/flutter/shell/common/shorebird/patch_cache_unittests.cc b/engine/src/flutter/shell/common/shorebird/patch_cache_unittests.cc new file mode 100644 index 0000000000000..51fc20f9ca562 --- /dev/null +++ b/engine/src/flutter/shell/common/shorebird/patch_cache_unittests.cc @@ -0,0 +1,70 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "flutter/runtime/shorebird/patch_cache.h" + +#include +#include + +#include "gtest/gtest.h" + +namespace flutter { +namespace testing { + +TEST(PatchCache, InstanceReturnsSameInstance) { + PatchCache& instance1 = PatchCache::Instance(); + PatchCache& instance2 = PatchCache::Instance(); + EXPECT_EQ(&instance1, &instance2); +} + +TEST(TryLoadFromPatch, ReturnsNullptrForEmptyPaths) { + std::vector empty_paths; + auto result = TryLoadFromPatch(empty_paths, "kDartIsolateSnapshotData"); + EXPECT_EQ(result, nullptr); +} + +TEST(TryLoadFromPatch, ReturnsNullptrForNonVmcodePath) { + std::vector paths = {"/path/to/some/file.so"}; + auto result = TryLoadFromPatch(paths, "kDartIsolateSnapshotData"); + EXPECT_EQ(result, nullptr); +} + +TEST(TryLoadFromPatch, ReturnsNullptrForVmSymbol) { + // Even with a .vmcode path, VM symbols should return nullptr + // (we can't actually load the file, but we can verify the symbol check) + std::vector paths = {"/path/to/patch.vmcode"}; + + // VM data symbol should return nullptr (patches don't contain VM snapshots) + auto result_vm_data = TryLoadFromPatch(paths, "kDartVmSnapshotData"); + EXPECT_EQ(result_vm_data, nullptr); + + // VM instructions symbol should return nullptr + auto result_vm_instrs = + TryLoadFromPatch(paths, "kDartVmSnapshotInstructions"); + EXPECT_EQ(result_vm_instrs, nullptr); +} + +TEST(TryLoadFromPatch, ReturnsNullptrForUnknownSymbol) { + std::vector paths = {"/path/to/patch.vmcode"}; + auto result = TryLoadFromPatch(paths, "kSomeUnknownSymbol"); + EXPECT_EQ(result, nullptr); +} + +TEST(TryLoadFromPatch, ChecksOnlyFirstPath) { + // Only the first path should be checked for .vmcode extension + std::vector paths = {"/path/to/regular.so", + "/path/to/patch.vmcode"}; + auto result = TryLoadFromPatch(paths, "kDartIsolateSnapshotData"); + // Should return nullptr because first path is not .vmcode + EXPECT_EQ(result, nullptr); +} + +TEST(PatchCache, GetOrLoadReturnsNullptrForNonexistentFile) { + auto result = + PatchCache::Instance().GetOrLoad("/nonexistent/path/to/file.vmcode"); + EXPECT_EQ(result, nullptr); +} + +} // namespace testing +} // namespace flutter diff --git a/engine/src/flutter/shell/common/shorebird/shorebird.cc b/engine/src/flutter/shell/common/shorebird/shorebird.cc new file mode 100644 index 0000000000000..71d336e90f8b0 --- /dev/null +++ b/engine/src/flutter/shell/common/shorebird/shorebird.cc @@ -0,0 +1,304 @@ + +#include "flutter/shell/common/shorebird/shorebird.h" + +#include +#include +#include +#include +#include + +#include "flutter/fml/command_line.h" +#include "flutter/fml/file.h" +#include "flutter/fml/macros.h" +#include "flutter/fml/mapping.h" +#include "flutter/fml/message_loop.h" +#include "flutter/fml/native_library.h" +#include "flutter/fml/paths.h" +#include "flutter/lib/ui/plugins/callback_cache.h" +#include "flutter/runtime/dart_snapshot.h" +#include "flutter/runtime/dart_vm.h" +#include "flutter/shell/common/shell.h" +#include "flutter/shell/common/shorebird/snapshots_data_handle.h" +#include "flutter/shell/common/shorebird/updater.h" +#include "flutter/shell/common/switches.h" +#include "fml/logging.h" +#include "shell/platform/embedder/embedder.h" +#include "third_party/dart/runtime/include/dart_tools_api.h" + +// Namespaced to avoid Google style warnings. +namespace flutter { + +// Old Android versions (e.g. the v16 ndk Flutter uses) don't always include a +// getauxval symbol, but the Rust ring crate assumes it exists: +// https://github.com/briansmith/ring/blob/fa25bf3a7403c9fe6458cb87bd8427be41225ca2/src/cpu/arm.rs#L22 +// It uses it to determine if the CPU supports AES instructions. +// Making this a weak symbol allows the linker to use a real version instead +// if it can find one. +// BoringSSL just reads from procfs instead, which is what we would do if +// we needed to implement this ourselves. Implementation looks straightforward: +// https://lwn.net/Articles/519085/ +// https://github.com/google/boringssl/blob/6ab4f0ae7f2db96d240eb61a5a8b4724e5a09b2f/crypto/cpu_arm_linux.c +#if defined(__ANDROID__) && defined(__arm__) +extern "C" __attribute__((weak)) unsigned long getauxval(unsigned long type) { + return 0; +} +#endif + +#if SHOREBIRD_USE_INTERPRETER +// Global references to the base (unpatched) snapshots from the App.framework. +// These are process-global because: +// 1. The Shorebird updater library is a process-global singleton with its own +// internal state. FileCallbacksImpl provides it access to the base snapshot +// data for patch generation/validation. +// 2. The base snapshots are immutable (baked into the IPA) so sharing them +// across isolate groups is safe. +// +// Note: This design doesn't support multiple engines with different base +// snapshots, but I'm not aware of any use cases for that on iOS. +static fml::RefPtr vm_snapshot; +static fml::RefPtr isolate_snapshot; + +void SetBaseSnapshot(Settings& settings) { + // These mappings happen to be to static data in the App.framework, but + // we still need to seem to hold onto the DartSnapshot objects to keep + // the mappings alive. + vm_snapshot = DartSnapshot::VMSnapshotFromSettings(settings); + isolate_snapshot = DartSnapshot::IsolateSnapshotFromSettings(settings); + Shorebird_SetBaseSnapshots(isolate_snapshot->GetDataMapping(), + isolate_snapshot->GetInstructionsMapping(), + vm_snapshot->GetDataMapping(), + vm_snapshot->GetInstructionsMapping()); +} +#endif // SHOREBIRD_USE_INTERPRETER + +class FileCallbacksImpl { + public: + static void* Open(); + static uintptr_t Read(void* file, uint8_t* buffer, uintptr_t length); + static int64_t Seek(void* file, int64_t offset, int32_t whence); + static void Close(void* file); +}; + +shorebird::FileCallbacks ShorebirdFileCallbacks() { + return { + .open = FileCallbacksImpl::Open, + .read = FileCallbacksImpl::Read, + .seek = FileCallbacksImpl::Seek, + .close = FileCallbacksImpl::Close, + }; +} + +// Given the contents of a yaml file, return the given value if it exists, +// otherwise return an empty string. +// Does not support nested keys. +std::string GetValueFromYaml(const std::string& yaml, const std::string& key) { + std::stringstream ss(yaml); + std::string line; + std::string prefix = key + ":"; + while (std::getline(ss, line, '\n')) { + if (line.find(prefix) != std::string::npos) { + auto ret = line.substr(line.find(prefix) + prefix.size()); + + // Remove leading and trailing spaces + while (!ret.empty() && std::isspace(ret.front())) { + ret.erase(0, 1); + } + while (!ret.empty() && std::isspace(ret.back())) { + ret.pop_back(); + } + return ret; + } + } + return ""; +} + +/// Newer api, used by Desktop implementations. +/// Does not directly manipulate Settings. +// TODO(eseidel): Consolidate this with the other ConfigureShorebird() API. +bool ConfigureShorebird(const ShorebirdConfigArgs& args, + std::string& patch_path) { + patch_path = args.release_app_library_path; + auto shorebird_updater_dir_name = "shorebird_updater"; + + // Parse app id from shorebird.yaml + std::string app_id = GetValueFromYaml(args.shorebird_yaml, "app_id"); + if (app_id.empty()) { + FML_LOG(ERROR) << "Shorebird updater: appid not found in shorebird.yaml"; + return false; + } + + auto code_cache_dir = fml::paths::JoinPaths( + {std::move(args.code_cache_path), shorebird_updater_dir_name, app_id}); + auto app_storage_dir = fml::paths::JoinPaths( + {std::move(args.app_storage_path), shorebird_updater_dir_name, app_id}); + + fml::CreateDirectory(fml::paths::GetCachesDirectory(), + {shorebird_updater_dir_name}, + fml::FilePermission::kReadWrite); + + // Combine version and version_code into a single string. + // We could also pass these separately through to the updater if needed. + auto release_version = args.release_version.version; + if (!args.release_version.build_number.empty()) { + release_version += "+" + args.release_version.build_number; + } + + shorebird::AppConfig config; + config.release_version = release_version; + config.original_libapp_paths = {args.release_app_library_path}; + config.app_storage_dir = app_storage_dir; + config.code_cache_dir = code_cache_dir; + config.file_callbacks = ShorebirdFileCallbacks(); + config.yaml_config = args.shorebird_yaml; + + bool init_result = shorebird::Updater::Instance().Init(config); + + // We do not support synchronous updates on launch, it's a terrible UX. + // Users can implement custom check-for-updates using + // package:shorebird_code_push. + // https://github.com/shorebirdtech/shorebird/issues/950 + + FML_LOG(INFO) << "Checking for active patch"; + shorebird::Updater::Instance().ValidateNextBootPatch(); + std::string active_path = shorebird::Updater::Instance().NextBootPatchPath(); + if (!active_path.empty()) { + patch_path = active_path; + FML_LOG(INFO) << "Shorebird updater: patch path: " << patch_path; + } else { + FML_LOG(INFO) << "Shorebird updater: no active patch."; + } + + // Note: shorebird_report_launch_start() is now called from TryLoadFromPatch() + // in runtime/shorebird/patch_cache.cc, right before the patched snapshot is + // actually loaded. This fixes issues with FlutterEngineGroup and other cases + // where ConfigureShorebird() is called but no Shell is created. + if (!init_result) { + return false; + } + + if (shorebird::Updater::Instance().ShouldAutoUpdate()) { + FML_LOG(INFO) << "Starting Shorebird update"; + shorebird::Updater::Instance().StartUpdateThread(); + } else { + FML_LOG(INFO) + << "Shorebird auto_update disabled, not checking for updates."; + } + + return true; +} + +/// Older api used by iOS and Android, directly manipulates Settings. +// TODO(eseidel): Consolidate this with the other ConfigureShorebird() API. +void ConfigureShorebird(std::string code_cache_path, + std::string app_storage_path, + Settings& settings, + const std::string& shorebird_yaml, + const std::string& version, + const std::string& version_code) { + // If you are crashing here, you probably are running Shorebird in a Debug + // config, where the AOT snapshot won't be linked into the process, and thus + // lookups will fail. Change your Scheme to Release to fix: + // https://github.com/flutter/flutter/wiki/Debugging-the-engine#debugging-ios-builds-with-xcode + FML_CHECK(DartSnapshot::VMSnapshotFromSettings(settings)) + << "XCode Scheme must be set to Release to use Shorebird"; + + auto shorebird_updater_dir_name = "shorebird_updater"; + + auto code_cache_dir = fml::paths::JoinPaths( + {std::move(code_cache_path), shorebird_updater_dir_name}); + auto app_storage_dir = fml::paths::JoinPaths( + {std::move(app_storage_path), shorebird_updater_dir_name}); + + fml::CreateDirectory(fml::paths::GetCachesDirectory(), + {shorebird_updater_dir_name}, + fml::FilePermission::kReadWrite); + + // Combine version and version_code into a single string. + // We could also pass these separately through to the updater if needed. + shorebird::AppConfig config; + config.release_version = version + "+" + version_code; + config.original_libapp_paths = settings.application_library_paths; + config.app_storage_dir = app_storage_dir; + config.code_cache_dir = code_cache_dir; + config.file_callbacks = ShorebirdFileCallbacks(); + config.yaml_config = shorebird_yaml; + + bool init_result = shorebird::Updater::Instance().Init(config); + + // We do not support synchronous updates on launch, it's a terrible UX. + // Users can implement custom check-for-updates using + // package:shorebird_code_push. + // https://github.com/shorebirdtech/shorebird/issues/950 + + // We only set the base snapshot on iOS for now. +#if SHOREBIRD_USE_INTERPRETER + SetBaseSnapshot(settings); +#endif + + shorebird::Updater::Instance().ValidateNextBootPatch(); + std::string active_path = shorebird::Updater::Instance().NextBootPatchPath(); + if (!active_path.empty()) { + FML_LOG(INFO) << "Shorebird updater: active path: " << active_path; + +#if SHOREBIRD_USE_INTERPRETER + // On iOS we add the patch to the front of the list instead of clearing + // the list, to allow dart_snapshot.cc to still find the base snapshot + // for the vm isolate. + settings.application_library_paths.insert( + settings.application_library_paths.begin(), active_path); +#else + settings.application_library_paths.clear(); + settings.application_library_paths.emplace_back(active_path); +#endif + } else { + FML_LOG(INFO) << "Shorebird updater: no active patch."; + } + + // Note: shorebird_report_launch_start() is now called from TryLoadFromPatch() + // in runtime/shorebird/patch_cache.cc, right before the patched snapshot is + // actually loaded. This fixes issues with FlutterEngineGroup and other cases + // where ConfigureShorebird() is called but no Shell is created. + + if (!init_result) { + return; + } + + if (shorebird::Updater::Instance().ShouldAutoUpdate()) { + FML_LOG(INFO) << "Starting Shorebird update"; + shorebird::Updater::Instance().StartUpdateThread(); + } else { + FML_LOG(INFO) + << "Shorebird auto_update disabled, not checking for updates."; + } +} + +void* FileCallbacksImpl::Open() { +#if SHOREBIRD_USE_INTERPRETER + return SnapshotsDataHandle::createForSnapshots(*vm_snapshot, + *isolate_snapshot) + .release(); +#else + // SnapshotsDataHandle exists on all platforms (for testing) but is only used + // on iOS. iOS patches are generated from just the Dart parts of the snapshot, + // excluding the Mach-O specific headers which contain dates and paths that + // make them change on every build. + return nullptr; +#endif // SHOREBIRD_USE_INTERPRETER +} + +uintptr_t FileCallbacksImpl::Read(void* file, + uint8_t* buffer, + uintptr_t length) { + return reinterpret_cast(file)->Read(buffer, length); +} + +int64_t FileCallbacksImpl::Seek(void* file, int64_t offset, int32_t whence) { + // Currently we only support blob handles. + return reinterpret_cast(file)->Seek(offset, whence); +} + +void FileCallbacksImpl::Close(void* file) { + delete reinterpret_cast(file); +} + +} // namespace flutter \ No newline at end of file diff --git a/engine/src/flutter/shell/common/shorebird/shorebird.h b/engine/src/flutter/shell/common/shorebird/shorebird.h new file mode 100644 index 0000000000000..ab5c9162ea0f2 --- /dev/null +++ b/engine/src/flutter/shell/common/shorebird/shorebird.h @@ -0,0 +1,59 @@ +#ifndef FLUTTER_SHELL_COMMON_SHOREBIRD_SHOREBIRD_H_ +#define FLUTTER_SHELL_COMMON_SHOREBIRD_SHOREBIRD_H_ + +#include "flutter/common/settings.h" +#include "flutter/fml/memory/ref_ptr.h" +#include "shell/platform/embedder/embedder.h" + +namespace flutter { + +class DartSnapshot; + +/// Version and build number of the release. +/// Used by ShorebirdConfigArgs. +struct ReleaseVersion { + std::string version; + std::string build_number; +}; + +/// Arguments for ConfigureShorebird. +/// Used by Desktop implementations. +struct ShorebirdConfigArgs { + std::string code_cache_path; + std::string app_storage_path; + std::string release_app_library_path; + std::string shorebird_yaml; + ReleaseVersion release_version; + + ShorebirdConfigArgs(std::string code_cache_path, + std::string app_storage_path, + std::string release_app_library_path, + std::string shorebird_yaml, + ReleaseVersion release_version) + : code_cache_path(code_cache_path), + app_storage_path(app_storage_path), + release_app_library_path(release_app_library_path), + shorebird_yaml(shorebird_yaml), + release_version(release_version) {} +}; + +/// Newer api, used by Desktop implementations. +/// Does not directly manipulate Settings. +bool ConfigureShorebird(const ShorebirdConfigArgs& args, + std::string& patch_path); + +/// Older api used by iOS and Android, directly manipulates Settings. +void ConfigureShorebird(std::string code_cache_path, + std::string app_storage_path, + Settings& settings, + const std::string& shorebird_yaml, + const std::string& version, + const std::string& version_code); + +/// Used for reading app_id from shorebird.yaml. +/// Exposed for testing. +std::string GetValueFromYaml(const std::string& yaml, const std::string& key); + +} // namespace flutter + +#endif // FLUTTER_SHELL_COMMON_SHOREBIRD_SHOREBIRD_H_ diff --git a/engine/src/flutter/shell/common/shorebird/shorebird_unittests.cc b/engine/src/flutter/shell/common/shorebird/shorebird_unittests.cc new file mode 100644 index 0000000000000..7c108ac1e6231 --- /dev/null +++ b/engine/src/flutter/shell/common/shorebird/shorebird_unittests.cc @@ -0,0 +1,21 @@ +#include "flutter/shell/common/shorebird/shorebird.h" + +#include "gtest/gtest.h" + +namespace flutter { +namespace testing { +TEST(Shorebird, GetValueFromYamlValueExists) { + std::string yaml = "appid: com.example.app\nversion: 1.0.0\n"; + std::string key = "appid"; + std::string value = GetValueFromYaml(yaml, key); + EXPECT_EQ(value, "com.example.app"); +} + +TEST(Shorebird, GetValueFromYamlValueDoesNotExist) { + std::string yaml = "appid: com.example.app\nversion: 1.0.0\n"; + std::string key = "appid2"; + std::string value = GetValueFromYaml(yaml, key); + EXPECT_EQ(value, ""); +} +} // namespace testing +} // namespace flutter \ No newline at end of file diff --git a/engine/src/flutter/shell/common/shorebird/snapshots_data_handle.cc b/engine/src/flutter/shell/common/shorebird/snapshots_data_handle.cc new file mode 100644 index 0000000000000..0c6c5a45450a1 --- /dev/null +++ b/engine/src/flutter/shell/common/shorebird/snapshots_data_handle.cc @@ -0,0 +1,144 @@ +#include "flutter/shell/common/shorebird/snapshots_data_handle.h" + +#include "third_party/dart/runtime/include/dart_native_api.h" + +namespace flutter { + +static std::unique_ptr DataMapping(const DartSnapshot& snapshot) { + auto ptr = snapshot.GetDataMapping(); + return std::make_unique(ptr, + Dart_SnapshotDataSize(ptr)); +} + +static std::unique_ptr InstructionsMapping( + const DartSnapshot& snapshot) { + auto ptr = snapshot.GetInstructionsMapping(); + return std::make_unique(ptr, + Dart_SnapshotInstrSize(ptr)); +} + +// The size of the snapshot data is the sum of the sizes of the blobs. +size_t SnapshotsDataHandle::FullSize() const { + size_t size = 0; + for (const auto& blob : blobs_) { + size += blob->GetSize(); + } + return size; +} + +// The offset into the snapshots data blobs as though they were a single +// contiguous buffer. +size_t SnapshotsDataHandle::AbsoluteOffsetForIndex(BlobsIndex index) { + if (index.blob >= blobs_.size()) { + FML_LOG(WARNING) << "Blob index " << index.blob + << " is larger than the number of blobs (" << blobs_.size() + << "). Returning full size (" << FullSize() << ")"; + return FullSize(); + } + if (index.offset > blobs_[index.blob]->GetSize()) { + FML_LOG(WARNING) << "Offset for blob " << index.blob << " (" << index.offset + << ") is larger than the blob size (" + << blobs_[index.blob]->GetSize() + << "). Returning index start of next blob"; + return AbsoluteOffsetForIndex({index.blob + 1, 0}); + } + size_t offset = 0; + for (size_t i = 0; i < index.blob; i++) { + offset += blobs_[i]->GetSize(); + } + offset += index.offset; + return offset; +} + +BlobsIndex SnapshotsDataHandle::IndexForAbsoluteOffset(int64_t offset, + BlobsIndex start_index) { + size_t start_offset = AbsoluteOffsetForIndex(start_index); + if (offset < 0) { + if ((size_t)abs(offset) > start_offset) { + FML_LOG(WARNING) + << "Offset is before the beginning of SnapshotsData. Returning 0, 0"; + return {0, 0}; + } + } else if (offset + start_offset >= FullSize()) { + FML_LOG(WARNING) << "Target offset is past the end of SnapshotsData (" + << offset + start_offset << ", blobs size:" << FullSize() + << "). Returning last blob index and offset"; + return {blobs_.size(), blobs_.back()->GetSize()}; + } + + size_t dest_offset = start_offset + offset; + BlobsIndex index = {0, 0}; + for (const auto& blob : blobs_) { + if (dest_offset < blob->GetSize()) { + // The remaining offset is within this blob. + index.offset = dest_offset; + break; + } + + index.blob++; + dest_offset -= blob->GetSize(); + } + return index; +} + +std::unique_ptr SnapshotsDataHandle::createForSnapshots( + const DartSnapshot& vm_snapshot, + const DartSnapshot& isolate_snapshot) { + // This needs to match the order in which the blobs are written out in + // analyze_snapshot --dump_blobs + std::vector> blobs; + blobs.push_back(DataMapping(vm_snapshot)); + blobs.push_back(DataMapping(isolate_snapshot)); + blobs.push_back(InstructionsMapping(vm_snapshot)); + blobs.push_back(InstructionsMapping(isolate_snapshot)); + return std::make_unique(std::move(blobs)); +} + +uintptr_t SnapshotsDataHandle::Read(uint8_t* buffer, uintptr_t length) { + uintptr_t bytes_read = 0; + // Copy current blob from current offset and possibly into the next blob + // until we have read length bytes. + while (bytes_read < length) { + if (current_index_.blob >= blobs_.size()) { + // We have read all blobs. + break; + } + intptr_t remaining_blob_length = + blobs_[current_index_.blob]->GetSize() - current_index_.offset; + if (remaining_blob_length <= 0) { + // We have read all bytes in this blob. + current_index_.blob++; + current_index_.offset = 0; + continue; + } + intptr_t bytes_to_read = fmin(length - bytes_read, remaining_blob_length); + memcpy(buffer + bytes_read, + blobs_[current_index_.blob]->GetMapping() + current_index_.offset, + bytes_to_read); + bytes_read += bytes_to_read; + current_index_.offset += bytes_to_read; + } + + return bytes_read; +} + +int64_t SnapshotsDataHandle::Seek(int64_t offset, int32_t whence) { + BlobsIndex start_index; + switch (whence) { + case SEEK_CUR: + start_index = current_index_; + break; + case SEEK_SET: + start_index = {0, 0}; + break; + case SEEK_END: + start_index = {blobs_.size(), blobs_.back()->GetSize()}; + break; + default: + FML_CHECK(false) << "Unrecognized whence value in Seek: " << whence; + } + current_index_ = IndexForAbsoluteOffset(offset, start_index); + return current_index_.offset; +} + +} // namespace flutter \ No newline at end of file diff --git a/engine/src/flutter/shell/common/shorebird/snapshots_data_handle.h b/engine/src/flutter/shell/common/shorebird/snapshots_data_handle.h new file mode 100644 index 0000000000000..50c4b8179b412 --- /dev/null +++ b/engine/src/flutter/shell/common/shorebird/snapshots_data_handle.h @@ -0,0 +1,48 @@ +#ifndef FLUTTER_SHELL_COMMON_SHOREBIRD_SNAPSHOTS_DATA_HANDLE_H_ +#define FLUTTER_SHELL_COMMON_SHOREBIRD_SNAPSHOTS_DATA_HANDLE_H_ + +#include +#include "flutter/fml/file.h" +#include "flutter/runtime/dart_snapshot.h" +#include "third_party/dart/runtime/include/dart_tools_api.h" + +namespace flutter { + +// An offset into an indexed collection of buffers. blob is the index of the +// buffer, and offset is the offset into that buffer. +struct BlobsIndex { + size_t blob; + size_t offset; +}; + +// Implements a POSIX file I/O interface which allows us to provide the four +// data blobs of a Dart snapshot (vm_data, vm_instructions, isolate_data, +// isolate_instructions) to Rust as though it were a single piece of memory. +class SnapshotsDataHandle { + public: + // This would ideally be private, but we need to be able to call it from the + // static createForSnapshots method. + explicit SnapshotsDataHandle(std::vector> blobs) + : blobs_(std::move(blobs)) {} + + static std::unique_ptr createForSnapshots( + const DartSnapshot& vm_snapshot, + const DartSnapshot& isolate_snapshot); + + uintptr_t Read(uint8_t* buffer, uintptr_t length); + int64_t Seek(int64_t offset, int32_t whence); + + // The sum of all the blobs' sizes. + size_t FullSize() const; + + private: + size_t AbsoluteOffsetForIndex(BlobsIndex index); + BlobsIndex IndexForAbsoluteOffset(int64_t offset, BlobsIndex startIndex); + + BlobsIndex current_index_ = {0, 0}; + std::vector> blobs_; +}; + +} // namespace flutter + +#endif // FLUTTER_SHELL_COMMON_SHOREBIRD_SNAPSHOTS_DATA_HANDLE_H_ diff --git a/engine/src/flutter/shell/common/shorebird/snapshots_data_handle_unittests.cc b/engine/src/flutter/shell/common/shorebird/snapshots_data_handle_unittests.cc new file mode 100644 index 0000000000000..2acf44a7b8973 --- /dev/null +++ b/engine/src/flutter/shell/common/shorebird/snapshots_data_handle_unittests.cc @@ -0,0 +1,177 @@ +#include +#include +#include + +#include "flutter/shell/common/shorebird/snapshots_data_handle.h" + +#include "flutter/fml/mapping.h" +#include "flutter/runtime/dart_snapshot.h" +#include "flutter/testing/testing.h" +#include "gmock/gmock.h" +#include "gtest/gtest.h" +#include "testing/fixture_test.h" + +namespace flutter { +namespace testing { + +std::unique_ptr MakeHandle( + std::vector& blobs) { + // Map the strings into non-owned mappings: + std::vector> mappings = {}; + for (auto& blob : blobs) { + std::unique_ptr mapping = + std::make_unique( + reinterpret_cast(blob.data()), blob.size()); + mappings.push_back(std::move(mapping)); + } + auto handle = + std::make_unique(std::move(mappings)); + return handle; +} + +TEST(SnapshotsDataHandle, Read) { + std::vector blobs = {"abc", "def", "ghi", "jkl"}; + std::unique_ptr blobs_handle = MakeHandle(blobs); + + const size_t buffer_size = 12; + uint8_t buffer[buffer_size]; + std::fill(buffer, buffer + buffer_size, 0); + blobs_handle->Read(buffer, 6); + + EXPECT_EQ(buffer[0], 'a'); + EXPECT_EQ(buffer[1], 'b'); + EXPECT_EQ(buffer[2], 'c'); + EXPECT_EQ(buffer[3], 'd'); + EXPECT_EQ(buffer[4], 'e'); + EXPECT_EQ(buffer[5], 'f'); + + // Only the first 6 bytes should have been read. + EXPECT_EQ(buffer[6], 0); +} + +TEST(SnapshotsDataHandle, ReadAfterSeekWithPositiveOffset) { + std::vector blobs = {"abc", "def", "ghi", "jkl"}; + std::unique_ptr blobs_handle = MakeHandle(blobs); + + const size_t buffer_size = 20; + uint8_t buffer[buffer_size]; + std::fill(buffer, buffer + buffer_size, 0); + + blobs_handle->Seek(4, SEEK_CUR); + blobs_handle->Read(buffer, 6); + + EXPECT_EQ(buffer[0], 'e'); + EXPECT_EQ(buffer[1], 'f'); + EXPECT_EQ(buffer[2], 'g'); + EXPECT_EQ(buffer[3], 'h'); + EXPECT_EQ(buffer[4], 'i'); + EXPECT_EQ(buffer[5], 'j'); + + // Only the first 6 bytes should have been read. + EXPECT_EQ(buffer[6], 0); +} + +TEST(SnapshotsDataHandle, ReadAfterSeekWithNegativeOffset) { + std::vector blobs = {"abc", "def", "ghi", "jkl"}; + std::unique_ptr blobs_handle = MakeHandle(blobs); + + const size_t buffer_size = 20; + uint8_t buffer[buffer_size]; + std::fill(buffer, buffer + buffer_size, 0); + + blobs_handle->Read(buffer, 5); + EXPECT_EQ(buffer[0], 'a'); + EXPECT_EQ(buffer[1], 'b'); + EXPECT_EQ(buffer[2], 'c'); + EXPECT_EQ(buffer[3], 'd'); + EXPECT_EQ(buffer[4], 'e'); + EXPECT_EQ(buffer[5], 0); + + // Reset buffer + std::fill(buffer, buffer + buffer_size, 0); + + // Read 5, seeked back 4, should start reading at offset 1 ('b') + blobs_handle->Seek(-4, SEEK_CUR); + blobs_handle->Read(buffer, 6); + + EXPECT_EQ(buffer[0], 'b'); + EXPECT_EQ(buffer[1], 'c'); + EXPECT_EQ(buffer[2], 'd'); + EXPECT_EQ(buffer[3], 'e'); + EXPECT_EQ(buffer[4], 'f'); + EXPECT_EQ(buffer[5], 'g'); + EXPECT_EQ(buffer[6], 0); +} + +TEST(SnapshotsDataHandle, SeekPastEnd) { + std::vector blobs = {"abc", "def", "ghi", "jkl"}; + std::unique_ptr blobs_handle = MakeHandle(blobs); + + const size_t buffer_size = 20; + uint8_t buffer[buffer_size]; + std::fill(buffer, buffer + buffer_size, 0); + + // Seek 1 past the end + blobs_handle->Seek(blobs_handle->FullSize() + 1, SEEK_CUR); + + // Seek back 2 bytes and read 2 bytes + blobs_handle->Seek(-2, SEEK_CUR); + blobs_handle->Read(buffer, 2); + + EXPECT_EQ(buffer[0], 'k'); + EXPECT_EQ(buffer[1], 'l'); +} + +TEST(SnapshotsDataHandle, SeekBeforeBeginning) { + std::vector blobs = {"abc", "def", "ghi", "jkl"}; + std::unique_ptr blobs_handle = MakeHandle(blobs); + + const size_t buffer_size = 20; + uint8_t buffer[buffer_size]; + std::fill(buffer, buffer + buffer_size, 0); + + // Seek before the start of the blobs and read the first 2 bytes. + blobs_handle->Seek(-2, SEEK_CUR); + blobs_handle->Read(buffer, 2); + + EXPECT_EQ(buffer[0], 'a'); + EXPECT_EQ(buffer[1], 'b'); +} + +TEST(SnapshotsDataHandle, SeekFromBeginning) { + std::vector blobs = {"abc", "def", "ghi", "jkl"}; + std::unique_ptr blobs_handle = MakeHandle(blobs); + + const size_t buffer_size = 20; + uint8_t buffer[buffer_size]; + std::fill(buffer, buffer + buffer_size, 0); + + // Seek 10 bytes from current (the beginning) + blobs_handle->Seek(10, SEEK_CUR); + + // Seek 2 bytes from the beginning and read 2 bytes + blobs_handle->Seek(2, SEEK_SET); + blobs_handle->Read(buffer, 2); + + EXPECT_EQ(buffer[0], 'c'); + EXPECT_EQ(buffer[1], 'd'); +} + +TEST(SnapshotsDataHandle, SeekFromEnd) { + std::vector blobs = {"abc", "def", "ghi", "jkl"}; + std::unique_ptr blobs_handle = MakeHandle(blobs); + + const size_t buffer_size = 20; + uint8_t buffer[buffer_size]; + std::fill(buffer, buffer + buffer_size, 0); + + // Seek 2 bytes from the end and read 2 bytes + blobs_handle->Seek(-2, SEEK_END); + blobs_handle->Read(buffer, 2); + + EXPECT_EQ(buffer[0], 'k'); + EXPECT_EQ(buffer[1], 'l'); +} + +} // namespace testing +} // namespace flutter diff --git a/engine/src/flutter/shell/common/shorebird/updater.cc b/engine/src/flutter/shell/common/shorebird/updater.cc new file mode 100644 index 0000000000000..63d993613c3f9 --- /dev/null +++ b/engine/src/flutter/shell/common/shorebird/updater.cc @@ -0,0 +1,202 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "flutter/shell/common/shorebird/updater.h" + +#include "flutter/fml/logging.h" + +#if SHOREBIRD_PLATFORM_SUPPORTED +#include "third_party/updater/library/include/updater.h" +#endif + +namespace flutter { +namespace shorebird { + +// Static member definitions +std::unique_ptr Updater::instance_; +std::mutex Updater::instance_mutex_; +std::atomic Updater::launch_started_{false}; +std::atomic Updater::launch_completed_{false}; + +Updater& Updater::Instance() { + std::lock_guard lock(instance_mutex_); + if (!instance_) { +#if SHOREBIRD_PLATFORM_SUPPORTED + instance_ = std::make_unique(); +#else + instance_ = std::make_unique(); +#endif + } + return *instance_; +} + +void Updater::SetInstanceForTesting(std::unique_ptr instance) { + std::lock_guard lock(instance_mutex_); + instance_ = std::move(instance); +} + +void Updater::ResetInstanceForTesting() { + std::lock_guard lock(instance_mutex_); + instance_.reset(); +} + +void Updater::ResetLaunchStateForTesting() { + launch_started_.store(false); + launch_completed_.store(false); +} + +void Updater::ReportLaunchStart() { + // Guard: only the first engine in a process should promote next_boot โ†’ + // current_boot in the Rust updater. See class-level comment for rationale. + bool expected = false; + if (!launch_started_.compare_exchange_strong(expected, true)) { + return; + } + DoReportLaunchStart(); +} + +void Updater::ReportLaunchSuccess() { + // Guard: only report success once per process. Subsequent engines reuse + // the same patch and don't need to re-confirm the boot. + bool expected = false; + if (!launch_completed_.compare_exchange_strong(expected, true)) { + return; + } + DoReportLaunchSuccess(); +} + +void Updater::ReportLaunchFailure() { + // Guard: only report failure once per process. + bool expected = false; + if (!launch_completed_.compare_exchange_strong(expected, true)) { + return; + } + DoReportLaunchFailure(); +} + +#if SHOREBIRD_PLATFORM_SUPPORTED +// RealUpdater implementation - wraps the Rust C API + +bool RealUpdater::Init(const AppConfig& config) { + // Convert paths to C strings + std::vector c_paths; + c_paths.reserve(config.original_libapp_paths.size()); + for (const auto& path : config.original_libapp_paths) { + c_paths.push_back(path.c_str()); + } + + AppParameters params; + params.release_version = config.release_version.c_str(); + params.original_libapp_paths = c_paths.data(); + params.original_libapp_paths_size = static_cast(c_paths.size()); + params.app_storage_dir = config.app_storage_dir.c_str(); + params.code_cache_dir = config.code_cache_dir.c_str(); + + // Convert our FileCallbacks to the Rust struct + ::FileCallbacks rust_callbacks; + rust_callbacks.open = config.file_callbacks.open; + rust_callbacks.read = config.file_callbacks.read; + rust_callbacks.seek = config.file_callbacks.seek; + rust_callbacks.close = config.file_callbacks.close; + + return shorebird_init(¶ms, rust_callbacks, config.yaml_config.c_str()); +} + +void RealUpdater::ValidateNextBootPatch() { + shorebird_validate_next_boot_patch(); +} + +std::string RealUpdater::NextBootPatchPath() { + char* c_path = shorebird_next_boot_patch_path(); + if (c_path == nullptr) { + return ""; + } + std::string path(c_path); + shorebird_free_string(c_path); + return path; +} + +void RealUpdater::DoReportLaunchStart() { + shorebird_report_launch_start(); +} + +void RealUpdater::DoReportLaunchSuccess() { + shorebird_report_launch_success(); +} + +void RealUpdater::DoReportLaunchFailure() { + shorebird_report_launch_failure(); +} + +bool RealUpdater::ShouldAutoUpdate() { + return shorebird_should_auto_update(); +} + +void RealUpdater::StartUpdateThread() { + shorebird_start_update_thread(); +} +#endif // SHOREBIRD_PLATFORM_SUPPORTED + +// MockUpdater implementation - for testing + +bool MockUpdater::Init(const AppConfig& config) { + init_count_++; + last_release_version_ = config.release_version; + last_yaml_config_ = config.yaml_config; + call_log_.push_back("Init"); + return init_result_; +} + +void MockUpdater::ValidateNextBootPatch() { + validate_count_++; + call_log_.push_back("ValidateNextBootPatch"); +} + +std::string MockUpdater::NextBootPatchPath() { + call_log_.push_back("NextBootPatchPath"); + return next_boot_patch_path_; +} + +void MockUpdater::DoReportLaunchStart() { + launch_start_count_++; + call_log_.push_back("ReportLaunchStart"); +} + +void MockUpdater::DoReportLaunchSuccess() { + launch_success_count_++; + call_log_.push_back("ReportLaunchSuccess"); +} + +void MockUpdater::DoReportLaunchFailure() { + launch_failure_count_++; + call_log_.push_back("ReportLaunchFailure"); +} + +bool MockUpdater::ShouldAutoUpdate() { + call_log_.push_back("ShouldAutoUpdate"); + return should_auto_update_; +} + +void MockUpdater::StartUpdateThread() { + start_update_thread_count_++; + call_log_.push_back("StartUpdateThread"); +} + +void MockUpdater::Reset() { + init_count_ = 0; + validate_count_ = 0; + launch_start_count_ = 0; + launch_success_count_ = 0; + launch_failure_count_ = 0; + start_update_thread_count_ = 0; + init_result_ = true; + should_auto_update_ = false; + next_boot_patch_path_.clear(); + last_release_version_.clear(); + last_yaml_config_.clear(); + call_log_.clear(); +} + +} // namespace shorebird +} // namespace flutter diff --git a/engine/src/flutter/shell/common/shorebird/updater.h b/engine/src/flutter/shell/common/shorebird/updater.h new file mode 100644 index 0000000000000..aeb2d1f2d90cc --- /dev/null +++ b/engine/src/flutter/shell/common/shorebird/updater.h @@ -0,0 +1,230 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef FLUTTER_SHELL_COMMON_SHOREBIRD_UPDATER_H_ +#define FLUTTER_SHELL_COMMON_SHOREBIRD_UPDATER_H_ + +#include +#include +#include +#include +#include +#include +#include + +namespace flutter { +namespace shorebird { + +/// File callbacks for iOS patch loading. +/// Mirrors the FileCallbacks struct from the Rust updater. +struct FileCallbacks { + void* (*open)(void); + uintptr_t (*read)(void* file_handle, uint8_t* buffer, uintptr_t count); + int64_t (*seek)(void* file_handle, int64_t offset, int32_t whence); + void (*close)(void* file_handle); +}; + +/// Configuration for initializing the Shorebird updater. +struct AppConfig { + /// Version string for this release (e.g., "1.0.0+1"). + std::string release_version; + + /// Paths to the original AOT libraries (libapp.so on Android, App.framework + /// on iOS). + std::vector original_libapp_paths; + + /// Directory for persistent updater state (survives app updates). + std::string app_storage_dir; + + /// Directory for cached artifacts (cleared on app updates). + std::string code_cache_dir; + + /// Callbacks for iOS patch file access (can be null callbacks on Android). + FileCallbacks file_callbacks; + + /// YAML configuration from shorebird.yaml. + std::string yaml_config; +}; + +/// Abstract interface for the Shorebird updater. +/// +/// This abstraction allows for: +/// 1. Mocking in tests without requiring the real Rust library +/// 2. Future migration from Rust to C++ implementation +/// 3. Test instrumentation (call counting, logging) +/// +/// ## Launch lifecycle (start/success/failure) +/// +/// The Rust updater uses a start/success/failure protocol to detect crashes: +/// - `ReportLaunchStart` copies `next_boot` โ†’ `current_boot` in the Rust +/// state. If the app crashes before `ReportLaunchSuccess`, the updater +/// assumes the patch caused the crash and rolls back on the next launch. +/// +/// These calls are guarded to execute at most once per process because: +/// 1. The Rust updater is a process-global singleton โ€” calling +/// `report_launch_start` multiple times would repeatedly copy `next_boot` +/// โ†’ `current_boot`, which could promote a newly-downloaded (but not yet +/// booted) patch to "current" even though the running engine loaded the +/// old snapshot. +/// 2. In add-to-app, multiple FlutterEngines may be created and destroyed +/// within a single process. Each engine creation resolves snapshots and +/// constructs a Shell, but we must only report launch start/success once +/// โ€” for the first engine that actually boots. Without this guard, a +/// background update that completes between engine creations would get +/// promoted to "current" by the second engine's `ReportLaunchStart`, +/// even though that engine is still running the old snapshot. +/// +/// Tests can call `ResetLaunchStateForTesting()` to re-enable the guards. +class Updater { + public: + virtual ~Updater() = default; + + /// Initialize the updater with configuration. + /// @param config Configuration containing release version, paths, and + /// callbacks + /// @return true if initialization succeeded + virtual bool Init(const AppConfig& config) = 0; + + /// Validate the next boot patch. If invalid, falls back to last good state. + virtual void ValidateNextBootPatch() = 0; + + /// Get the path to the patch that will boot on next run. + /// @return Path to patch, or empty string if no patch available + virtual std::string NextBootPatchPath() = 0; + + // Boot lifecycle methods โ€” guarded to run at most once per process. + // Callers may call these freely; subsequent calls after the first are + // silently ignored. + void ReportLaunchStart(); + void ReportLaunchSuccess(); + void ReportLaunchFailure(); + + // Update checking + virtual bool ShouldAutoUpdate() = 0; + virtual void StartUpdateThread() = 0; + + // Singleton access + static Updater& Instance(); + + // Test support - allows injecting a mock implementation + static void SetInstanceForTesting(std::unique_ptr instance); + static void ResetInstanceForTesting(); + + /// Resets the once-per-process launch guards so tests can verify + /// start/success/failure calls on fresh Updater instances. + static void ResetLaunchStateForTesting(); + + protected: + Updater() = default; + + // Subclass hooks โ€” called by the public guarded methods above. + virtual void DoReportLaunchStart() = 0; + virtual void DoReportLaunchSuccess() = 0; + virtual void DoReportLaunchFailure() = 0; + + private: + static std::unique_ptr instance_; + static std::mutex instance_mutex_; + + // Once-per-process guards for launch lifecycle. + static std::atomic launch_started_; + static std::atomic launch_completed_; +}; + +/// No-op implementation for unsupported platforms. +/// All methods are safe to call but do nothing. +class NoOpUpdater : public Updater { + public: + NoOpUpdater() = default; + ~NoOpUpdater() override = default; + + bool Init(const AppConfig& config) override { return true; } + void ValidateNextBootPatch() override {} + std::string NextBootPatchPath() override { return ""; } + void DoReportLaunchStart() override {} + void DoReportLaunchSuccess() override {} + void DoReportLaunchFailure() override {} + bool ShouldAutoUpdate() override { return false; } + void StartUpdateThread() override {} +}; + +#if SHOREBIRD_PLATFORM_SUPPORTED +/// Production implementation that wraps the Rust updater C API. +/// Only available on supported platforms (Android, iOS, macOS, Windows, Linux). +class RealUpdater : public Updater { + public: + RealUpdater() = default; + ~RealUpdater() override = default; + + bool Init(const AppConfig& config) override; + void ValidateNextBootPatch() override; + std::string NextBootPatchPath() override; + void DoReportLaunchStart() override; + void DoReportLaunchSuccess() override; + void DoReportLaunchFailure() override; + bool ShouldAutoUpdate() override; + void StartUpdateThread() override; +}; +#endif // SHOREBIRD_PLATFORM_SUPPORTED + +/// Mock implementation for testing. +/// Tracks call counts and can be queried to verify behavior. +class MockUpdater : public Updater { + public: + MockUpdater() = default; + ~MockUpdater() override = default; + + bool Init(const AppConfig& config) override; + void ValidateNextBootPatch() override; + std::string NextBootPatchPath() override; + void DoReportLaunchStart() override; + void DoReportLaunchSuccess() override; + void DoReportLaunchFailure() override; + bool ShouldAutoUpdate() override; + void StartUpdateThread() override; + + // Test accessors + int init_count() const { return init_count_; } + int validate_count() const { return validate_count_; } + int launch_start_count() const { return launch_start_count_; } + int launch_success_count() const { return launch_success_count_; } + int launch_failure_count() const { return launch_failure_count_; } + int start_update_thread_count() const { return start_update_thread_count_; } + const std::vector& call_log() const { return call_log_; } + + // Last init parameters (for verification) + const std::string& last_release_version() const { + return last_release_version_; + } + const std::string& last_yaml_config() const { return last_yaml_config_; } + + // Test configuration + void set_init_result(bool value) { init_result_ = value; } + void set_should_auto_update(bool value) { should_auto_update_ = value; } + void set_next_boot_patch_path(const std::string& path) { + next_boot_patch_path_ = path; + } + + // Reset all counters and logs + void Reset(); + + private: + int init_count_ = 0; + int validate_count_ = 0; + int launch_start_count_ = 0; + int launch_success_count_ = 0; + int launch_failure_count_ = 0; + int start_update_thread_count_ = 0; + bool init_result_ = true; + bool should_auto_update_ = false; + std::string next_boot_patch_path_; + std::string last_release_version_; + std::string last_yaml_config_; + std::vector call_log_; +}; + +} // namespace shorebird +} // namespace flutter + +#endif // FLUTTER_SHELL_COMMON_SHOREBIRD_UPDATER_H_ diff --git a/engine/src/flutter/shell/common/shorebird/updater_unittests.cc b/engine/src/flutter/shell/common/shorebird/updater_unittests.cc new file mode 100644 index 0000000000000..def93050e885d --- /dev/null +++ b/engine/src/flutter/shell/common/shorebird/updater_unittests.cc @@ -0,0 +1,183 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "flutter/shell/common/shorebird/updater.h" + +#include "gtest/gtest.h" + +namespace flutter { +namespace shorebird { +namespace testing { + +class UpdaterTest : public ::testing::Test { + protected: + void SetUp() override { + // Install a mock for each test and reset the once-per-process guards + // so each test starts with a clean slate. + auto mock = std::make_unique(); + mock_ = mock.get(); + Updater::SetInstanceForTesting(std::move(mock)); + Updater::ResetLaunchStateForTesting(); + } + + void TearDown() override { + mock_ = nullptr; + Updater::ResetInstanceForTesting(); + Updater::ResetLaunchStateForTesting(); + } + + MockUpdater* mock_ = nullptr; +}; + +// ReportLaunchStart is guarded to run at most once per process. +// The second call should be silently ignored. +TEST_F(UpdaterTest, ReportLaunchStartOnlyCallsOnce) { + EXPECT_EQ(mock_->launch_start_count(), 0); + + Updater::Instance().ReportLaunchStart(); + EXPECT_EQ(mock_->launch_start_count(), 1); + + // Second call is a no-op due to the once-per-process guard. + Updater::Instance().ReportLaunchStart(); + EXPECT_EQ(mock_->launch_start_count(), 1); +} + +TEST_F(UpdaterTest, ReportLaunchSuccessOnlyCallsOnce) { + EXPECT_EQ(mock_->launch_success_count(), 0); + + Updater::Instance().ReportLaunchSuccess(); + EXPECT_EQ(mock_->launch_success_count(), 1); + + // Second call is a no-op. + Updater::Instance().ReportLaunchSuccess(); + EXPECT_EQ(mock_->launch_success_count(), 1); +} + +TEST_F(UpdaterTest, ReportLaunchFailureOnlyCallsOnce) { + EXPECT_EQ(mock_->launch_failure_count(), 0); + + Updater::Instance().ReportLaunchFailure(); + EXPECT_EQ(mock_->launch_failure_count(), 1); + + // Second call is a no-op. + Updater::Instance().ReportLaunchFailure(); + EXPECT_EQ(mock_->launch_failure_count(), 1); +} + +TEST_F(UpdaterTest, MockUpdaterTracksShouldAutoUpdate) { + mock_->set_should_auto_update(false); + EXPECT_FALSE(Updater::Instance().ShouldAutoUpdate()); + + mock_->set_should_auto_update(true); + EXPECT_TRUE(Updater::Instance().ShouldAutoUpdate()); +} + +TEST_F(UpdaterTest, MockUpdaterTracksStartUpdateThreadCalls) { + EXPECT_EQ(mock_->start_update_thread_count(), 0); + + Updater::Instance().StartUpdateThread(); + EXPECT_EQ(mock_->start_update_thread_count(), 1); +} + +TEST_F(UpdaterTest, MockUpdaterCallLogRecordsSequence) { + EXPECT_TRUE(mock_->call_log().empty()); + + Updater::Instance().ReportLaunchStart(); + Updater::Instance().ShouldAutoUpdate(); + Updater::Instance().ReportLaunchSuccess(); + + const auto& log = mock_->call_log(); + ASSERT_EQ(log.size(), 3u); + EXPECT_EQ(log[0], "ReportLaunchStart"); + EXPECT_EQ(log[1], "ShouldAutoUpdate"); + EXPECT_EQ(log[2], "ReportLaunchSuccess"); +} + +TEST_F(UpdaterTest, MockUpdaterResetClearsState) { + Updater::Instance().ReportLaunchStart(); + Updater::Instance().ReportLaunchSuccess(); + mock_->set_should_auto_update(true); + + EXPECT_EQ(mock_->launch_start_count(), 1); + EXPECT_EQ(mock_->launch_success_count(), 1); + EXPECT_TRUE(mock_->ShouldAutoUpdate()); + + mock_->Reset(); + + EXPECT_EQ(mock_->launch_start_count(), 0); + EXPECT_EQ(mock_->launch_success_count(), 0); + // Check call_log before ShouldAutoUpdate() since the method adds to call_log + EXPECT_TRUE(mock_->call_log().empty()); + EXPECT_FALSE(mock_->ShouldAutoUpdate()); +} + +// ReportLaunchStart and ReportLaunchSuccess are paired once per process. +// The Rust updater no-ops both when no patch is booting. +TEST_F(UpdaterTest, LaunchStartAndSuccessArePairedOncePerProcess) { + Updater::Instance().ReportLaunchStart(); + Updater::Instance().ReportLaunchSuccess(); + + EXPECT_EQ(mock_->launch_start_count(), 1); + EXPECT_EQ(mock_->launch_success_count(), 1); + const auto& log = mock_->call_log(); + ASSERT_EQ(log.size(), 2u); + EXPECT_EQ(log[0], "ReportLaunchStart"); + EXPECT_EQ(log[1], "ReportLaunchSuccess"); +} + +// ReportLaunchStart and ReportLaunchFailure are paired once per process. +TEST_F(UpdaterTest, LaunchStartAndFailureArePairedOncePerProcess) { + Updater::Instance().ReportLaunchStart(); + Updater::Instance().ReportLaunchFailure(); + + EXPECT_EQ(mock_->launch_start_count(), 1); + EXPECT_EQ(mock_->launch_failure_count(), 1); + const auto& log = mock_->call_log(); + ASSERT_EQ(log.size(), 2u); + EXPECT_EQ(log[0], "ReportLaunchStart"); + EXPECT_EQ(log[1], "ReportLaunchFailure"); +} + +// Simulates the add-to-app scenario: multiple engines call ReportLaunchStart +// and ReportLaunchSuccess, but only the first should actually reach the +// updater. This prevents the Rust updater from promoting a newly-downloaded +// patch to "current_boot" when subsequent engines are still running the +// original snapshot. +TEST_F(UpdaterTest, MultipleEnginesOnlyReportOnce) { + // First engine boots. + Updater::Instance().ReportLaunchStart(); + Updater::Instance().ReportLaunchSuccess(); + + // Second engine boots โ€” these should be no-ops. + Updater::Instance().ReportLaunchStart(); + Updater::Instance().ReportLaunchSuccess(); + + EXPECT_EQ(mock_->launch_start_count(), 1); + EXPECT_EQ(mock_->launch_success_count(), 1); + + const auto& log = mock_->call_log(); + ASSERT_EQ(log.size(), 2u); + EXPECT_EQ(log[0], "ReportLaunchStart"); + EXPECT_EQ(log[1], "ReportLaunchSuccess"); +} + +// ResetLaunchStateForTesting re-enables the guards, allowing tests to +// verify launch calls on a fresh state. +TEST_F(UpdaterTest, ResetLaunchStateReenablesGuards) { + Updater::Instance().ReportLaunchStart(); + Updater::Instance().ReportLaunchSuccess(); + EXPECT_EQ(mock_->launch_start_count(), 1); + EXPECT_EQ(mock_->launch_success_count(), 1); + + Updater::ResetLaunchStateForTesting(); + + Updater::Instance().ReportLaunchStart(); + Updater::Instance().ReportLaunchSuccess(); + EXPECT_EQ(mock_->launch_start_count(), 2); + EXPECT_EQ(mock_->launch_success_count(), 2); +} + +} // namespace testing +} // namespace shorebird +} // namespace flutter diff --git a/engine/src/flutter/shell/platform/android/BUILD.gn b/engine/src/flutter/shell/platform/android/BUILD.gn index c271ed6193114..c3b50e380bacf 100644 --- a/engine/src/flutter/shell/platform/android/BUILD.gn +++ b/engine/src/flutter/shell/platform/android/BUILD.gn @@ -171,6 +171,7 @@ source_set("flutter_shell_native_src") { "//flutter/runtime", "//flutter/runtime:libdart", "//flutter/shell/common", + "//flutter/shell/common/shorebird", "//flutter/shell/platform/android/context", "//flutter/shell/platform/android/external_view_embedder", "//flutter/shell/platform/android/jni", @@ -221,6 +222,7 @@ android_java_sources = [ "io/flutter/Log.java", "io/flutter/app/FlutterApplication.java", "io/flutter/embedding/android/AndroidTouchProcessor.java", + "io/flutter/embedding/android/ContentSizingFlag.java", "io/flutter/embedding/android/ExclusiveAppComponent.java", "io/flutter/embedding/android/FlutterActivity.java", "io/flutter/embedding/android/FlutterActivityAndFragmentDelegate.java", @@ -808,8 +810,10 @@ if (target_cpu != "x86") { } } -if (host_os == "linux" && - (target_cpu == "x64" || target_cpu == "arm64" || target_cpu == "riscv64")) { +# Build analyze_snapshot for 64-bit target CPUs on linux. +# Or always targeting arm64 for Shorebird builds. +if ((host_os == "linux" && (target_cpu == "x64" || target_cpu == "riscv64")) || + target_cpu == "arm64") { zip_bundle("analyze_snapshot") { deps = [ "$dart_src/runtime/bin:analyze_snapshot($host_toolchain)" ] diff --git a/engine/src/flutter/shell/platform/android/android_exports.lst b/engine/src/flutter/shell/platform/android/android_exports.lst index 198bff773dd74..9b924e7738cd4 100644 --- a/engine/src/flutter/shell/platform/android/android_exports.lst +++ b/engine/src/flutter/shell/platform/android/android_exports.lst @@ -11,6 +11,18 @@ _binary_icudtl_dat_size; InternalFlutterGpu*; kInternalFlutterGpu*; + shorebird_init; + shorebird_active_path; + shorebird_active_patch_number; + shorebird_free_string; + shorebird_free_update_result; + shorebird_check_for_downloadable_update; + shorebird_check_for_update; + shorebird_update; + shorebird_update_with_result; + shorebird_next_boot_patch_number; + shorebird_current_boot_patch_number; + shorebird_validate_next_boot_patch; local: *; }; diff --git a/engine/src/flutter/shell/platform/android/external_view_embedder/external_view_embedder.cc b/engine/src/flutter/shell/platform/android/external_view_embedder/external_view_embedder.cc index ac800734df869..9b74a0e38f225 100644 --- a/engine/src/flutter/shell/platform/android/external_view_embedder/external_view_embedder.cc +++ b/engine/src/flutter/shell/platform/android/external_view_embedder/external_view_embedder.cc @@ -7,6 +7,7 @@ #include "flutter/common/constants.h" #include "flutter/fml/synchronization/waitable_event.h" #include "flutter/fml/trace_event.h" +#include "fml/make_copyable.h" namespace flutter { @@ -225,7 +226,11 @@ void AndroidExternalViewEmbedder::PrepareFlutterView( DestroySurfaces(); } surface_pool_->SetFrameSize(frame_size); - jni_facade_->MaybeResizeSurfaceView(frame_size.width, frame_size.height); + + task_runners_.GetPlatformTaskRunner()->PostTask( + fml::MakeCopyable([jni_facade = jni_facade_, frame_size = frame_size]() { + jni_facade->MaybeResizeSurfaceView(frame_size.width, frame_size.height); + })); frame_size_ = frame_size; device_pixel_ratio_ = device_pixel_ratio; diff --git a/engine/src/flutter/shell/platform/android/external_view_embedder/external_view_embedder_2.cc b/engine/src/flutter/shell/platform/android/external_view_embedder/external_view_embedder_2.cc index d1a7d19ebca4b..607eb1d683834 100644 --- a/engine/src/flutter/shell/platform/android/external_view_embedder/external_view_embedder_2.cc +++ b/engine/src/flutter/shell/platform/android/external_view_embedder/external_view_embedder_2.cc @@ -245,7 +245,11 @@ void AndroidExternalViewEmbedder2::PrepareFlutterView( DestroySurfaces(); } surface_pool_->SetFrameSize(frame_size); - jni_facade_->MaybeResizeSurfaceView(frame_size.width, frame_size.height); + + task_runners_.GetPlatformTaskRunner()->PostTask( + fml::MakeCopyable([jni_facade = jni_facade_, frame_size = frame_size]() { + jni_facade->MaybeResizeSurfaceView(frame_size.width, frame_size.height); + })); frame_size_ = frame_size; device_pixel_ratio_ = device_pixel_ratio; diff --git a/engine/src/flutter/shell/platform/android/external_view_embedder/external_view_embedder_unittests.cc b/engine/src/flutter/shell/platform/android/external_view_embedder/external_view_embedder_unittests.cc index 048f18fc12ee0..fd867b27a864e 100644 --- a/engine/src/flutter/shell/platform/android/external_view_embedder/external_view_embedder_unittests.cc +++ b/engine/src/flutter/shell/platform/android/external_view_embedder/external_view_embedder_unittests.cc @@ -11,10 +11,13 @@ #include "flutter/flow/embedded_views.h" #include "flutter/flow/surface.h" #include "flutter/fml/raster_thread_merger.h" +#include "flutter/fml/synchronization/waitable_event.h" #include "flutter/fml/thread.h" +#include "flutter/shell/common/thread_host.h" #include "flutter/shell/platform/android/jni/jni_mock.h" #include "flutter/shell/platform/android/surface/android_surface.h" #include "flutter/shell/platform/android/surface/android_surface_mock.h" +#include "flutter/testing/testing.h" #include "gmock/gmock.h" #include "gtest/gtest.h" @@ -1068,8 +1071,18 @@ TEST(AndroidExternalViewEmbedder, Teardown) { TEST(AndroidExternalViewEmbedder, MaybeResizeSurfaceView) { auto jni_mock = std::make_shared(); auto android_context = AndroidContext(AndroidRenderingAPI::kSoftware); + ThreadHost thread_host("io.flutter.test." + GetCurrentTestName() + ".", + ThreadHost::Type::kPlatform | ThreadHost::Type::kIo | + ThreadHost::Type::kUi | ThreadHost::Type::kRaster); + TaskRunners task_runners( + "test", + thread_host.platform_thread->GetTaskRunner(), // platform + thread_host.raster_thread->GetTaskRunner(), // raster + thread_host.ui_thread->GetTaskRunner(), // ui + thread_host.io_thread->GetTaskRunner() // io + ); auto embedder = std::make_unique( - android_context, jni_mock, nullptr, GetTaskRunnersForFixture()); + android_context, jni_mock, nullptr, task_runners); fml::Thread rasterizer_thread("rasterizer"); auto raster_thread_merger = @@ -1081,6 +1094,11 @@ TEST(AndroidExternalViewEmbedder, MaybeResizeSurfaceView) { EXPECT_CALL(*jni_mock, MaybeResizeSurfaceView(100, 200)); embedder->PrepareFlutterView(DlISize(100, 200), 1.0); + + fml::AutoResetWaitableEvent latch; + fml::TaskRunner::RunNowOrPostTask(task_runners.GetPlatformTaskRunner(), + [&latch]() { latch.Signal(); }); + latch.Wait(); } TEST(AndroidExternalViewEmbedder, TeardownDoesNotCallJNIMethod) { diff --git a/engine/src/flutter/shell/platform/android/flutter_main.cc b/engine/src/flutter/shell/platform/android/flutter_main.cc index d14ac40029645..45e369f5b3a9d 100644 --- a/engine/src/flutter/shell/platform/android/flutter_main.cc +++ b/engine/src/flutter/shell/platform/android/flutter_main.cc @@ -20,6 +20,7 @@ #include "flutter/fml/platform/android/paths_android.h" #include "flutter/lib/ui/plugins/callback_cache.h" #include "flutter/runtime/dart_vm.h" +#include "flutter/shell/common/shorebird/shorebird.h" #include "flutter/shell/common/switches.h" #include "flutter/shell/platform/android/android_context_vk_impeller.h" #include "flutter/shell/platform/android/android_rendering_selector.h" @@ -93,6 +94,9 @@ void FlutterMain::Init(JNIEnv* env, jstring kernelPath, jstring appStoragePath, jstring engineCachesPath, + jstring shorebirdYaml, + jstring version, + jstring versionCode, jlong initTimeMillis, jint api_level) { std::vector args; @@ -151,8 +155,18 @@ void FlutterMain::Init(JNIEnv* env, flutter::DartCallbackCache::SetCachePath( fml::jni::JavaStringToString(env, appStoragePath)); - fml::paths::InitializeAndroidCachesPath( - fml::jni::JavaStringToString(env, engineCachesPath)); + auto code_cache_path = fml::jni::JavaStringToString(env, engineCachesPath); + auto app_storage_path = fml::jni::JavaStringToString(env, appStoragePath); + fml::paths::InitializeAndroidCachesPath(code_cache_path); + +#if FLUTTER_RELEASE + std::string shorebird_yaml = fml::jni::JavaStringToString(env, shorebirdYaml); + std::string version_string = fml::jni::JavaStringToString(env, version); + std::string version_code_string = + fml::jni::JavaStringToString(env, versionCode); + ConfigureShorebird(code_cache_path, app_storage_path, settings, + shorebird_yaml, version_string, version_code_string); +#endif flutter::DartCallbackCache::LoadCacheFromDisk(); @@ -245,6 +259,7 @@ bool FlutterMain::Register(JNIEnv* env) { { .name = "nativeInit", .signature = "(Landroid/content/Context;[Ljava/lang/String;Ljava/" + "lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/" "lang/String;Ljava/lang/String;Ljava/lang/String;JI)V", .fnPtr = reinterpret_cast(&Init), }, diff --git a/engine/src/flutter/shell/platform/android/flutter_main.h b/engine/src/flutter/shell/platform/android/flutter_main.h index dda959801bc32..cf03f889ec30e 100644 --- a/engine/src/flutter/shell/platform/android/flutter_main.h +++ b/engine/src/flutter/shell/platform/android/flutter_main.h @@ -44,6 +44,9 @@ class FlutterMain { jstring kernelPath, jstring appStoragePath, jstring engineCachesPath, + jstring shorebirdYaml, + jstring version, + jstring versionCode, jlong initTimeMillis, jint api_level); diff --git a/engine/src/flutter/shell/platform/android/io/flutter/embedding/android/ContentSizingFlag.java b/engine/src/flutter/shell/platform/android/io/flutter/embedding/android/ContentSizingFlag.java new file mode 100644 index 0000000000000..34fda7f228128 --- /dev/null +++ b/engine/src/flutter/shell/platform/android/io/flutter/embedding/android/ContentSizingFlag.java @@ -0,0 +1,39 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package io.flutter.embedding.android; + +import android.content.Context; +import android.content.pm.ApplicationInfo; +import android.content.pm.PackageManager; +import android.content.pm.PackageManager.NameNotFoundException; +import android.os.Bundle; +import io.flutter.Log; + +class ContentSizingFlag { + + private static final String TAG = "ContentSizingFlag"; + + // Default to DISABLED + private static final boolean DEFAULT = false; + + private static final String ENABLE_CONTENT_SIZING = + "io.flutter.embedding.android.EnableContentSizing"; + + static boolean isEnabled(Context context) { + // Ensure that the context is actually the application context. + final Context appContext = context.getApplicationContext(); + Bundle metaData = null; + try { + ApplicationInfo applicationInfo = + appContext + .getPackageManager() + .getApplicationInfo(appContext.getPackageName(), PackageManager.GET_META_DATA); + metaData = applicationInfo.metaData; + } catch (NameNotFoundException ex) { + Log.e(TAG, "Could not get metadata"); + } + return metaData != null ? metaData.getBoolean(ENABLE_CONTENT_SIZING, DEFAULT) : DEFAULT; + } +} diff --git a/engine/src/flutter/shell/platform/android/io/flutter/embedding/android/FlutterImageView.java b/engine/src/flutter/shell/platform/android/io/flutter/embedding/android/FlutterImageView.java index 695d2fd3ffb83..c028a95c05826 100644 --- a/engine/src/flutter/shell/platform/android/io/flutter/embedding/android/FlutterImageView.java +++ b/engine/src/flutter/shell/platform/android/io/flutter/embedding/android/FlutterImageView.java @@ -47,6 +47,8 @@ public class FlutterImageView extends View implements RenderSurface { @Nullable private Bitmap currentBitmap; @Nullable private FlutterRenderer flutterRenderer; + private boolean isContentSizingEnabled = false; + public ImageReader getImageReader() { return imageReader; } @@ -92,11 +94,16 @@ public FlutterImageView(@NonNull Context context, @NonNull AttributeSet attrs) { private void init() { setAlpha(0.0f); + isContentSizingEnabled = ContentSizingFlag.isEnabled(getContext()); } @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { - FlutterMeasureSpec.onMeasure(widthMeasureSpec, heightMeasureSpec, this::setMeasuredDimension); + if (isContentSizingEnabled) { + FlutterMeasureSpec.onMeasure(widthMeasureSpec, heightMeasureSpec, this::setMeasuredDimension); + } else { + super.onMeasure(widthMeasureSpec, heightMeasureSpec); + } } private static void logW(String format, Object... args) { diff --git a/engine/src/flutter/shell/platform/android/io/flutter/embedding/android/FlutterSurfaceView.java b/engine/src/flutter/shell/platform/android/io/flutter/embedding/android/FlutterSurfaceView.java index eee534ef499ad..55fb557c53d9c 100644 --- a/engine/src/flutter/shell/platform/android/io/flutter/embedding/android/FlutterSurfaceView.java +++ b/engine/src/flutter/shell/platform/android/io/flutter/embedding/android/FlutterSurfaceView.java @@ -40,6 +40,8 @@ public class FlutterSurfaceView extends SurfaceView implements RenderSurface { private boolean isPaused = false; @Nullable private FlutterRenderer flutterRenderer; + private boolean isContentSizingEnabled = false; + private boolean shouldNotify() { return flutterRenderer != null && !isPaused; } @@ -116,6 +118,8 @@ private void init() { setZOrderOnTop(true); } + isContentSizingEnabled = ContentSizingFlag.isEnabled(getContext()); + // Grab a reference to our underlying Surface and register callbacks with that Surface so we // can monitor changes and forward those changes on to native Flutter code. getHolder().addCallback(surfaceHolderCallbackCompat); @@ -123,7 +127,11 @@ private void init() { @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { - FlutterMeasureSpec.onMeasure(widthMeasureSpec, heightMeasureSpec, this::setMeasuredDimension); + if (isContentSizingEnabled) { + FlutterMeasureSpec.onMeasure(widthMeasureSpec, heightMeasureSpec, this::setMeasuredDimension); + } else { + super.onMeasure(widthMeasureSpec, heightMeasureSpec); + } } // This is a work around for TalkBack. diff --git a/engine/src/flutter/shell/platform/android/io/flutter/embedding/android/FlutterTextureView.java b/engine/src/flutter/shell/platform/android/io/flutter/embedding/android/FlutterTextureView.java index 0fc516d1a55c4..a0f9b8e32910b 100644 --- a/engine/src/flutter/shell/platform/android/io/flutter/embedding/android/FlutterTextureView.java +++ b/engine/src/flutter/shell/platform/android/io/flutter/embedding/android/FlutterTextureView.java @@ -39,6 +39,8 @@ public class FlutterTextureView extends TextureView implements RenderSurface { @Nullable private FlutterRenderer flutterRenderer; @Nullable private Surface renderSurface; + private boolean isContentSizingEnabled = false; + private boolean shouldNotify() { return flutterRenderer != null && !isPaused; } @@ -116,11 +118,17 @@ private void init() { // Listen for when our underlying SurfaceTexture becomes available, changes size, or // gets destroyed, and take the appropriate actions. setSurfaceTextureListener(surfaceTextureListener); + + isContentSizingEnabled = ContentSizingFlag.isEnabled(getContext()); } @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { - FlutterMeasureSpec.onMeasure(widthMeasureSpec, heightMeasureSpec, this::setMeasuredDimension); + if (isContentSizingEnabled) { + FlutterMeasureSpec.onMeasure(widthMeasureSpec, heightMeasureSpec, this::setMeasuredDimension); + } else { + super.onMeasure(widthMeasureSpec, heightMeasureSpec); + } } @Nullable @@ -273,4 +281,4 @@ private void disconnectSurfaceFromRenderer() { renderSurface = null; } } -} \ No newline at end of file +} diff --git a/engine/src/flutter/shell/platform/android/io/flutter/embedding/android/FlutterView.java b/engine/src/flutter/shell/platform/android/io/flutter/embedding/android/FlutterView.java index 736d98e54709a..0be71298ef327 100644 --- a/engine/src/flutter/shell/platform/android/io/flutter/embedding/android/FlutterView.java +++ b/engine/src/flutter/shell/platform/android/io/flutter/embedding/android/FlutterView.java @@ -122,6 +122,9 @@ public class FlutterView extends FrameLayout // Maximum size allowed for a content sized view. @VisibleForTesting static final int CONTENT_SIZING_MAX = 2 << 12; + // Flag to enable content sizing. + @VisibleForTesting boolean isContentSizingEnabled = false; + // Internal view hierarchy references. @Nullable private FlutterSurfaceView flutterSurfaceView; @Nullable private FlutterTextureView flutterTextureView; @@ -423,6 +426,8 @@ private void init() { addView(flutterImageView); } + isContentSizingEnabled = ContentSizingFlag.isEnabled(getContext()); + // FlutterView needs to be focusable so that the InputMethodManager can interact with it. setFocusable(true); setFocusableInTouchMode(true); @@ -520,7 +525,7 @@ protected void onSizeChanged(int width, int height, int oldWidth, int oldHeight) viewportMetrics.width = width; viewportMetrics.height = height; - if (heightMode == MeasureSpec.UNSPECIFIED) { + if (isContentSizingEnabled && heightMode == MeasureSpec.UNSPECIFIED) { Log.d(TAG, "FlutterView height is set to wrap content - updating viewport metrics to max"); viewportMetrics.minHeight = 0; viewportMetrics.maxHeight = CONTENT_SIZING_MAX; @@ -528,7 +533,7 @@ protected void onSizeChanged(int width, int height, int oldWidth, int oldHeight) viewportMetrics.minHeight = viewportMetrics.height; viewportMetrics.maxHeight = viewportMetrics.height; } - if (widthMode == MeasureSpec.UNSPECIFIED) { + if (isContentSizingEnabled && widthMode == MeasureSpec.UNSPECIFIED) { Log.d(TAG, "FlutterView width is set to wrap content - updating viewport metrics to max"); viewportMetrics.minWidth = 0; viewportMetrics.maxWidth = CONTENT_SIZING_MAX; @@ -1171,7 +1176,9 @@ public void attachToFlutterEngine(@NonNull FlutterEngine flutterEngine) { isFlutterUiDisplayed = flutterRenderer.isDisplayingFlutterUi(); renderSurface.attachToRenderer(flutterRenderer); flutterRenderer.addIsDisplayingFlutterUiListener(flutterUiDisplayListener); - flutterRenderer.addResizingFlutterUiListener(flutterUiResizeListener); + if (isContentSizingEnabled) { + flutterRenderer.addResizingFlutterUiListener(flutterUiResizeListener); + } // Initialize various components that know how to process Android View I/O // in a way that Flutter understands. @@ -1318,7 +1325,9 @@ public void detachFromFlutterEngine() { FlutterRenderer flutterRenderer = flutterEngine.getRenderer(); isFlutterUiDisplayed = false; flutterRenderer.removeIsDisplayingFlutterUiListener(flutterUiDisplayListener); - flutterRenderer.removeResizingFlutterUiListener(flutterUiResizeListener); + if (isContentSizingEnabled) { + flutterRenderer.removeResizingFlutterUiListener(flutterUiResizeListener); + } flutterRenderer.stopRenderingToSurface(); flutterRenderer.setSemanticsEnabled(false); diff --git a/engine/src/flutter/shell/platform/android/io/flutter/embedding/engine/FlutterJNI.java b/engine/src/flutter/shell/platform/android/io/flutter/embedding/engine/FlutterJNI.java index 91248a6f6ef18..ad7428e74f557 100644 --- a/engine/src/flutter/shell/platform/android/io/flutter/embedding/engine/FlutterJNI.java +++ b/engine/src/flutter/shell/platform/android/io/flutter/embedding/engine/FlutterJNI.java @@ -8,6 +8,8 @@ import android.annotation.SuppressLint; import android.content.Context; +import android.content.pm.PackageInfo; +import android.content.pm.PackageManager; import android.content.res.AssetManager; import android.graphics.Bitmap; import android.graphics.SurfaceTexture; @@ -42,6 +44,10 @@ import io.flutter.view.AccessibilityBridge; import io.flutter.view.FlutterCallbackInformation; import io.flutter.view.TextureRegistry; +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; import java.lang.ref.WeakReference; import java.nio.ByteBuffer; import java.util.ArrayList; @@ -178,6 +184,9 @@ private static native void nativeInit( @Nullable String bundlePath, @NonNull String appStoragePath, @NonNull String engineCachesPath, + @Nullable String shorebirdYaml, + @Nullable String version, + @Nullable String versionCode, long initTimeMillis, int apiLevel); @@ -206,8 +215,47 @@ public void init( Log.w(TAG, "FlutterJNI.init called more than once"); } + String version = null; + String versionCode = null; + try { + PackageInfo packageInfo = + context.getPackageManager().getPackageInfo(context.getPackageName(), 0); + version = packageInfo.versionName; + if (Build.VERSION.SDK_INT >= API_LEVELS.API_28) { + versionCode = String.valueOf(packageInfo.getLongVersionCode()); + } else { + versionCode = String.valueOf(packageInfo.versionCode); + } + } catch (PackageManager.NameNotFoundException e) { + Log.e(TAG, "Failed to read app version. Shorebird updater can't run.", e); + } + + String shorebirdYaml = null; + try { + InputStream yaml = context.getAssets().open("flutter_assets/shorebird.yaml"); + BufferedReader r = new BufferedReader(new InputStreamReader(yaml)); + StringBuilder total = new StringBuilder(); + for (String line; (line = r.readLine()) != null; ) { + total.append(line).append('\n'); + } + shorebirdYaml = total.toString(); + Log.d(TAG, "shorebird.yaml: " + shorebirdYaml); + } catch (IOException e) { + Log.e(TAG, "Failed to load shorebird.yaml", e); + Log.e(TAG, "Did you remember to include shorebird.yaml in your pubspec.yaml's assets?"); + } + FlutterJNI.nativeInit( - context, args, bundlePath, appStoragePath, engineCachesPath, initTimeMillis, apiLevel); + context, + args, + bundlePath, + appStoragePath, + engineCachesPath, + shorebirdYaml, + version, + versionCode, + initTimeMillis, + apiLevel); FlutterJNI.initCalled = true; } @@ -1316,8 +1364,8 @@ public void destroyOverlaySurfaces() { platformViewsController.destroyOverlaySurfaces(); } - // This will get called on the raster thread. @SuppressWarnings("unused") + @UiThread public void maybeResizeSurfaceView(int width, int height) { for (FlutterUiResizeListener listener : flutterUiResizeListeners) { listener.resizeEngineView(width, height); diff --git a/engine/src/flutter/shell/platform/android/test/io/flutter/embedding/android/FlutterViewTest.java b/engine/src/flutter/shell/platform/android/test/io/flutter/embedding/android/FlutterViewTest.java index d02072c0f6951..b6a2212a3481b 100644 --- a/engine/src/flutter/shell/platform/android/test/io/flutter/embedding/android/FlutterViewTest.java +++ b/engine/src/flutter/shell/platform/android/test/io/flutter/embedding/android/FlutterViewTest.java @@ -1307,6 +1307,7 @@ public SettingsChannel.MessageBuilder answer(InvocationOnMock invocation) public void onMeasure_whenWrapContent_sendsCorrectViewportMetrics() { FlutterSurfaceView flutterSurfaceView = spy(new FlutterSurfaceView(ctx)); FlutterView flutterView = new FlutterView(ctx, flutterSurfaceView); + flutterView.isContentSizingEnabled = true; FlutterEngine flutterEngine = spy(new FlutterEngine(ctx, mockFlutterLoader, mockFlutterJni)); FlutterRenderer flutterRenderer = spy(new FlutterRenderer(mockFlutterJni)); when(flutterEngine.getRenderer()).thenReturn(flutterRenderer); diff --git a/engine/src/flutter/shell/platform/darwin/ios/BUILD.gn b/engine/src/flutter/shell/platform/darwin/ios/BUILD.gn index 911ee431e2516..777b6ae4a64bb 100644 --- a/engine/src/flutter/shell/platform/darwin/ios/BUILD.gn +++ b/engine/src/flutter/shell/platform/darwin/ios/BUILD.gn @@ -194,12 +194,14 @@ source_set("flutter_framework_source") { deps = [ ":ios_gpu_configuration", + "$dart_src/runtime/bin:elf_loader", "//flutter/common", "//flutter/common/graphics", "//flutter/fml", "//flutter/lib/ui", "//flutter/runtime", "//flutter/shell/common", + "//flutter/shell/common/shorebird", "//flutter/shell/platform/common:common_cpp_input", "//flutter/shell/platform/darwin/common", "//flutter/shell/platform/darwin/common:framework_common", diff --git a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterDartProject.mm b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterDartProject.mm index 31a49a856a4a5..45660d1cd637d 100644 --- a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterDartProject.mm +++ b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterDartProject.mm @@ -13,6 +13,8 @@ #include "flutter/common/constants.h" #include "flutter/fml/build_config.h" +#include "flutter/fml/paths.h" +#include "flutter/shell/common/shorebird/shorebird.h" #include "flutter/shell/common/switches.h" #import "flutter/shell/platform/darwin/common/InternalFlutterSwiftCommon/InternalFlutterSwiftCommon.h" #include "flutter/shell/platform/darwin/common/command_line.h" @@ -92,10 +94,12 @@ static BOOL DoesHardwareSupportWideGamut() { } if (flutter::DartVM::IsRunningPrecompiledCode()) { + NSLog(@"SANITY CHECK: Running precompiled code."); if (hasExplicitBundle) { NSString* executablePath = bundle.executablePath; if ([[NSFileManager defaultManager] fileExistsAtPath:executablePath]) { settings.application_library_paths.push_back(executablePath.UTF8String); + NSLog(@"Using precompiled library from %@", executablePath); } } @@ -107,6 +111,7 @@ static BOOL DoesHardwareSupportWideGamut() { NSString* executablePath = [NSBundle bundleWithPath:libraryPath].executablePath; if (executablePath.length > 0) { settings.application_library_paths.push_back(executablePath.UTF8String); + NSLog(@"Using library from %@", libraryPath); } } } @@ -121,6 +126,7 @@ static BOOL DoesHardwareSupportWideGamut() { [NSBundle bundleWithPath:applicationFrameworkPath].executablePath; if (executablePath.length > 0) { settings.application_library_paths.push_back(executablePath.UTF8String); + NSLog(@"Using App.framework from %@", applicationFrameworkPath); } } } @@ -152,6 +158,33 @@ static BOOL DoesHardwareSupportWideGamut() { } } + NSString* assetsPath = [NSString stringWithUTF8String:settings.assets_path.c_str()]; + NSLog(@"ASSET PATH %@", assetsPath); + + // FIXME: This may not be the correct path (e.g., should it include the organization id?) + // See + // https://developer.apple.com/library/archive/documentation/FileManagement/Conceptual/FileSystemProgrammingGuide/FileSystemOverview/FileSystemOverview.html#//apple_ref/doc/uid/TP40010672-CH2-SW13 + // /private/var/mobile/Containers/Data/Application/264477BF-6E38-47C9-AAD9-532BB842F197/Library/Application + // Support/shorebird/shorebird_updater + std::string cache_path = + fml::paths::JoinPaths({getenv("HOME"), "Library/Application Support/shorebird"}); + NSURL* shorebirdYamlPath = [NSURL URLWithString:@"shorebird.yaml" + relativeToURL:[NSURL fileURLWithPath:assetsPath]]; + NSString* appVersion = [mainBundle objectForInfoDictionaryKey:@"CFBundleShortVersionString"]; + NSString* appBuildNumber = [mainBundle objectForInfoDictionaryKey:@"CFBundleVersion"]; + NSString* shorebirdYamlContents = [NSString stringWithContentsOfURL:shorebirdYamlPath + encoding:NSUTF8StringEncoding + error:nil]; + if (shorebirdYamlContents != nil) { + // Note: we intentionally pass cache_path twice. We provide two different directories + // to ConfigureShorebird because Android differentiates between data that persists + // between releases and data that does not. iOS does not make this distinction. + flutter::ConfigureShorebird(cache_path, cache_path, settings, shorebirdYamlContents.UTF8String, + appVersion.UTF8String, appBuildNumber.UTF8String); + } else { + NSLog(@"Failed to find shorebird.yaml, not starting updater."); + } + // Domain network configuration // Disabled in https://github.com/flutter/flutter/issues/72723. // Re-enable in https://github.com/flutter/flutter/issues/54448. diff --git a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterEngine.mm b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterEngine.mm index 90035fef8b832..41c5a66176bdf 100644 --- a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterEngine.mm +++ b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterEngine.mm @@ -147,6 +147,9 @@ @interface FlutterEngine () diff --git a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterPluginAppLifeCycleDelegate.mm b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterPluginAppLifeCycleDelegate.mm index 2f2d1b8f9329a..abff30d30834f 100644 --- a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterPluginAppLifeCycleDelegate.mm +++ b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterPluginAppLifeCycleDelegate.mm @@ -82,7 +82,7 @@ - (BOOL)isSelectorAddedDynamically:(SEL)selector { } - (BOOL)hasPluginThatRespondsToSelector:(SEL)selector { - for (NSObject* delegate in [_delegates allObjects]) { + for (NSObject* delegate in _delegates.allObjects) { if (!delegate) { continue; } @@ -175,7 +175,9 @@ - (BOOL)sceneWillConnectFallback:(UISceneConnectionOptions*)connectionOptions { - (BOOL)application:(UIApplication*)application didFinishLaunchingWithOptions:(NSDictionary*)launchOptions isFallbackForScene:(BOOL)isFallback { - for (NSObject* delegate in _delegates) { + // Use a snapshot of the delegates to allow plugins to add or remove themselves + // during the notification loop without causing a mutation crash. + for (NSObject* delegate in _delegates.allObjects) { if (!delegate || (isFallback && [self pluginSupportsSceneLifecycle:delegate])) { continue; } @@ -223,7 +225,7 @@ - (BOOL)application:(UIApplication*)application self.didForwardApplicationWillLaunch = YES; } flutter::DartCallbackCache::LoadCacheFromDisk(); - for (NSObject* delegate in [_delegates allObjects]) { + for (NSObject* delegate in _delegates.allObjects) { if (!delegate) { continue; } @@ -275,7 +277,7 @@ - (void)sceneDidEnterBackgroundFallback { - (void)applicationDidEnterBackground:(UIApplication*)application isFallbackForScene:(BOOL)isFallback { - for (NSObject* delegate in _delegates) { + for (NSObject* delegate in _delegates.allObjects) { if (!delegate || (isFallback && [self pluginSupportsSceneLifecycle:delegate])) { continue; } @@ -310,7 +312,7 @@ - (void)sceneWillEnterForegroundFallback { - (void)applicationWillEnterForeground:(UIApplication*)application isFallbackForScene:(BOOL)isFallback { - for (NSObject* delegate in _delegates) { + for (NSObject* delegate in _delegates.allObjects) { if (!delegate || (isFallback && [self pluginSupportsSceneLifecycle:delegate])) { continue; } @@ -339,7 +341,7 @@ - (void)sceneWillResignActiveFallback { - (void)applicationWillResignActive:(UIApplication*)application isFallbackForScene:(BOOL)isFallback { - for (NSObject* delegate in _delegates) { + for (NSObject* delegate in _delegates.allObjects) { if (!delegate || (isFallback && [self pluginSupportsSceneLifecycle:delegate])) { continue; } @@ -367,7 +369,7 @@ - (void)sceneDidBecomeActiveFallback { } - (void)applicationDidBecomeActive:(UIApplication*)application isFallbackForScene:(BOOL)isFallback { - for (NSObject* delegate in _delegates) { + for (NSObject* delegate in _delegates.allObjects) { if (!delegate || (isFallback && [self pluginSupportsSceneLifecycle:delegate])) { continue; } @@ -380,7 +382,7 @@ - (void)applicationDidBecomeActive:(UIApplication*)application isFallbackForScen - (void)handleWillTerminate:(NSNotification*)notification NS_EXTENSION_UNAVAILABLE_IOS("Disallowed in app extensions") { UIApplication* application = [UIApplication sharedApplication]; - for (NSObject* delegate in _delegates) { + for (NSObject* delegate in _delegates.allObjects) { if (!delegate) { continue; } @@ -394,7 +396,7 @@ - (void)handleWillTerminate:(NSNotification*)notification #pragma GCC diagnostic ignored "-Wdeprecated-declarations" - (void)application:(UIApplication*)application didRegisterUserNotificationSettings:(UIUserNotificationSettings*)notificationSettings { - for (NSObject* delegate in _delegates) { + for (NSObject* delegate in _delegates.allObjects) { if (!delegate) { continue; } @@ -407,7 +409,7 @@ - (void)application:(UIApplication*)application - (void)application:(UIApplication*)application didRegisterForRemoteNotificationsWithDeviceToken:(NSData*)deviceToken { - for (NSObject* delegate in _delegates) { + for (NSObject* delegate in _delegates.allObjects) { if (!delegate) { continue; } @@ -420,7 +422,7 @@ - (void)application:(UIApplication*)application - (void)application:(UIApplication*)application didFailToRegisterForRemoteNotificationsWithError:(NSError*)error { - for (NSObject* delegate in _delegates) { + for (NSObject* delegate in _delegates.allObjects) { if (!delegate) { continue; } @@ -433,7 +435,7 @@ - (void)application:(UIApplication*)application - (void)application:(UIApplication*)application didReceiveRemoteNotification:(NSDictionary*)userInfo fetchCompletionHandler:(void (^)(UIBackgroundFetchResult result))completionHandler { - for (NSObject* delegate in _delegates) { + for (NSObject* delegate in _delegates.allObjects) { if (!delegate) { continue; } @@ -451,7 +453,7 @@ - (void)application:(UIApplication*)application #pragma GCC diagnostic ignored "-Wdeprecated-declarations" - (void)application:(UIApplication*)application didReceiveLocalNotification:(UILocalNotification*)notification { - for (NSObject* delegate in _delegates) { + for (NSObject* delegate in _delegates.allObjects) { if (!delegate) { continue; } @@ -466,7 +468,7 @@ - (void)userNotificationCenter:(UNUserNotificationCenter*)center willPresentNotification:(UNNotification*)notification withCompletionHandler: (void (^)(UNNotificationPresentationOptions options))completionHandler { - for (NSObject* delegate in _delegates) { + for (NSObject* delegate in _delegates.allObjects) { if ([delegate respondsToSelector:_cmd]) { [delegate userNotificationCenter:center willPresentNotification:notification @@ -478,7 +480,7 @@ - (void)userNotificationCenter:(UNUserNotificationCenter*)center - (void)userNotificationCenter:(UNUserNotificationCenter*)center didReceiveNotificationResponse:(UNNotificationResponse*)response withCompletionHandler:(void (^)(void))completionHandler { - for (id delegate in _delegates) { + for (id delegate in _delegates.allObjects) { if ([delegate respondsToSelector:_cmd]) { [delegate userNotificationCenter:center didReceiveNotificationResponse:response @@ -509,7 +511,7 @@ - (BOOL)application:(UIApplication*)application openURL:(NSURL*)url options:(NSDictionary*)options isFallbackForScene:(BOOL)isFallback { - for (NSObject* delegate in _delegates) { + for (NSObject* delegate in _delegates.allObjects) { if (!delegate || (isFallback && [self pluginSupportsSceneLifecycle:delegate])) { continue; } @@ -551,7 +553,7 @@ - (BOOL)application:(UIApplication*)application } - (BOOL)application:(UIApplication*)application handleOpenURL:(NSURL*)url { - for (NSObject* delegate in _delegates) { + for (NSObject* delegate in _delegates.allObjects) { if (!delegate) { continue; } @@ -568,7 +570,7 @@ - (BOOL)application:(UIApplication*)application openURL:(NSURL*)url sourceApplication:(NSString*)sourceApplication annotation:(id)annotation { - for (NSObject* delegate in _delegates) { + for (NSObject* delegate in _delegates.allObjects) { if (!delegate) { continue; } @@ -609,7 +611,7 @@ - (BOOL)application:(UIApplication*)application performActionForShortcutItem:(UIApplicationShortcutItem*)shortcutItem completionHandler:(void (^)(BOOL succeeded))completionHandler isFallbackForScene:(BOOL)isFallback { - for (NSObject* delegate in _delegates) { + for (NSObject* delegate in _delegates.allObjects) { if (!delegate || (isFallback && [self pluginSupportsSceneLifecycle:delegate])) { continue; } @@ -628,7 +630,7 @@ - (BOOL)application:(UIApplication*)application - (BOOL)application:(UIApplication*)application handleEventsForBackgroundURLSession:(nonnull NSString*)identifier completionHandler:(nonnull void (^)())completionHandler { - for (NSObject* delegate in _delegates) { + for (NSObject* delegate in _delegates.allObjects) { if (!delegate) { continue; } @@ -645,7 +647,7 @@ - (BOOL)application:(UIApplication*)application - (BOOL)application:(UIApplication*)application performFetchWithCompletionHandler:(void (^)(UIBackgroundFetchResult result))completionHandler { - for (NSObject* delegate in _delegates) { + for (NSObject* delegate in _delegates.allObjects) { if (!delegate) { continue; } @@ -682,7 +684,7 @@ - (BOOL)application:(UIApplication*)application continueUserActivity:(NSUserActivity*)userActivity restorationHandler:(void (^)(NSArray*))restorationHandler isFallbackForScene:(BOOL)isFallback { - for (NSObject* delegate in _delegates) { + for (NSObject* delegate in _delegates.allObjects) { if (!delegate || (isFallback && [self pluginSupportsSceneLifecycle:delegate])) { continue; } diff --git a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterPluginAppLifeCycleDelegateTest.mm b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterPluginAppLifeCycleDelegateTest.mm index edb903cc60b11..b4490ca3324a1 100644 --- a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterPluginAppLifeCycleDelegateTest.mm +++ b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterPluginAppLifeCycleDelegateTest.mm @@ -13,6 +13,25 @@ FLUTTER_ASSERT_ARC +// --- Test Category to avoid modifying the original production source code --- +@interface FlutterPluginAppLifeCycleDelegate (TestUtils) +- (void)removeDelegate:(NSObject*)delegate; +@end + +@implementation FlutterPluginAppLifeCycleDelegate (TestUtils) +- (void)removeDelegate:(NSObject*)delegate { + // Access the private _delegates member via Key-Value Coding (KVC) + NSPointerArray* delegates = [self valueForKey:@"_delegates"]; + for (NSUInteger i = 0; i < delegates.count; i++) { + if ([delegates pointerAtIndex:i] == (__bridge void*)delegate) { + [delegates removePointerAtIndex:i]; + break; + } + } +} +@end +// ----------------------------------------------------------------------- + @protocol TestFlutterPluginWithSceneEvents @@ -81,6 +100,30 @@ - (BOOL)application:(UIApplication*)application } @end +/** + * A mock plugin that simulates behavior causing a mutation crash. + * This represents a "downstream" mutation where a plugin adds or removes + * delegates during a lifecycle notification loop. + */ +@interface MutatingPlugin : NSObject +@property(nonatomic, weak) FlutterPluginAppLifeCycleDelegate* lifecycleDelegate; +@property(nonatomic, assign) BOOL shouldAdd; // YES = Add, NO = Remove +@end + +@implementation MutatingPlugin +- (BOOL)application:(UIApplication*)application + didFinishLaunchingWithOptions:(NSDictionary*)launchOptions { + if (self.shouldAdd) { + // Case 1: Add a new delegate during the loop over _delegates + [self.lifecycleDelegate addDelegate:[[FakePlugin alloc] init]]; + } else { + // Case 2: Remove itself during the loop over _delegates via TestUtils category + [(id)self.lifecycleDelegate removeDelegate:self]; + } + return YES; +} +@end + @interface FlutterPluginAppLifeCycleDelegateTest : XCTestCase @end @@ -567,4 +610,36 @@ - (void)testApplicationDidFinishLaunchingSceneFallbackNoForward { didFinishLaunchingWithOptions:options]); } +- (void)testCanAddDelegateDuringEnumeration { + FlutterPluginAppLifeCycleDelegate* delegate = [[FlutterPluginAppLifeCycleDelegate alloc] init]; + MutatingPlugin* mutatingPlugin = [[MutatingPlugin alloc] init]; + mutatingPlugin.lifecycleDelegate = delegate; + mutatingPlugin.shouldAdd = YES; // Add Mode + + [delegate addDelegate:mutatingPlugin]; + + // Validation that [_delegates allObjects] (snapshotting) prevents NSGenericException crash + // when a plugin adds another plugin during the dispatch loop. + BOOL result = [delegate application:[UIApplication sharedApplication] + didFinishLaunchingWithOptions:@{}]; + + XCTAssertTrue(result); +} + +- (void)testCanRemoveSelfDuringEnumeration { + FlutterPluginAppLifeCycleDelegate* delegate = [[FlutterPluginAppLifeCycleDelegate alloc] init]; + MutatingPlugin* mutatingPlugin = [[MutatingPlugin alloc] init]; + mutatingPlugin.lifecycleDelegate = delegate; + mutatingPlugin.shouldAdd = NO; // Delete Mode + + [delegate addDelegate:mutatingPlugin]; + + // Validation that [_delegates allObjects] (snapshotting) prevents crash + // when a plugin removes itself via removeDelegate during the dispatch loop. + BOOL result = [delegate application:[UIApplication sharedApplication] + didFinishLaunchingWithOptions:@{}]; + + XCTAssertTrue(result); +} + @end diff --git a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterViewController.mm b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterViewController.mm index 48480ec61cd9d..841bb7c60e6fc 100644 --- a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterViewController.mm +++ b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterViewController.mm @@ -39,6 +39,7 @@ #import "flutter/third_party/spring_animation/spring_animation.h" FLUTTER_ASSERT_ARC +#import static constexpr int kMicrosecondsPerSecond = 1000 * 1000; static constexpr CGFloat kScrollViewContentSize = 2.0; @@ -585,29 +586,13 @@ - (void)loadView { return pointer_data; } -static void SendFakeTouchEvent(UIScreen* screen, - FlutterEngine* engine, - CGPoint location, - flutter::PointerData::Change change) { - const CGFloat scale = screen.scale; - flutter::PointerData pointer_data = [[engine viewController] generatePointerDataForFake]; - pointer_data.physical_x = location.x * scale; - pointer_data.physical_y = location.y * scale; - auto packet = std::make_unique(/*count=*/1); - pointer_data.change = change; - packet->SetPointerData(0, pointer_data); - [engine dispatchPointerDataPacket:std::move(packet)]; -} - - (BOOL)scrollViewShouldScrollToTop:(UIScrollView*)scrollView { if (!self.engine) { return NO; } - CGPoint statusBarPoint = CGPointZero; - UIScreen* screen = self.flutterScreenIfViewLoaded; - if (screen) { - SendFakeTouchEvent(screen, self.engine, statusBarPoint, flutter::PointerData::Change::kDown); - SendFakeTouchEvent(screen, self.engine, statusBarPoint, flutter::PointerData::Change::kUp); + if (self.isViewLoaded) { + // Status bar taps before the UI is visible should be ignored. + [self.engine onStatusBarTap]; } return NO; } diff --git a/engine/src/flutter/shell/platform/darwin/macos/BUILD.gn b/engine/src/flutter/shell/platform/darwin/macos/BUILD.gn index bc69c85f797a0..6c5e1f4eb928b 100644 --- a/engine/src/flutter/shell/platform/darwin/macos/BUILD.gn +++ b/engine/src/flutter/shell/platform/darwin/macos/BUILD.gn @@ -144,12 +144,14 @@ source_set("flutter_framework_source") { ":macos_gpu_configuration", "//flutter/flow", "//flutter/fml", + "//flutter/shell/common/shorebird", "//flutter/shell/platform/common:common_cpp_accessibility", "//flutter/shell/platform/common:common_cpp_core", "//flutter/shell/platform/common:common_cpp_enums", "//flutter/shell/platform/common:common_cpp_input", "//flutter/shell/platform/common:common_cpp_isolate_scope", "//flutter/shell/platform/common:common_cpp_switches", + "//flutter/shell/platform/darwin/common", "//flutter/shell/platform/darwin/common:availability_version_check", "//flutter/shell/platform/darwin/common:framework_common", "//flutter/shell/platform/darwin/graphics", diff --git a/engine/src/flutter/shell/platform/darwin/macos/framework/Source/FlutterEngine.mm b/engine/src/flutter/shell/platform/darwin/macos/framework/Source/FlutterEngine.mm index 8ba35a05761e3..a514611719a56 100644 --- a/engine/src/flutter/shell/platform/darwin/macos/framework/Source/FlutterEngine.mm +++ b/engine/src/flutter/shell/platform/darwin/macos/framework/Source/FlutterEngine.mm @@ -11,6 +11,8 @@ #include #include "flutter/common/constants.h" +#include "flutter/fml/paths.h" +#include "flutter/shell/common/shorebird/shorebird.h" #include "flutter/shell/platform/common/app_lifecycle_state.h" #include "flutter/shell/platform/common/engine_switches.h" #include "flutter/shell/platform/embedder/embedder.h" @@ -656,6 +658,40 @@ - (void)onFocusChangeRequest:(const FlutterViewFocusChangeRequest*)request { } } +- (BOOL)configureShorebird:(NSString**)patchPath { + NSLog(@"[shorebird] setting up non-linker shorebird"); + NSString* bundlePath = + [[NSBundle bundleWithURL:[NSBundle.mainBundle.privateFrameworksURL + URLByAppendingPathComponent:@"App.framework"]] bundlePath]; + bundlePath = [bundlePath stringByAppendingString:@"/App"]; + NSString* assetsPath = _project.assetsPath; + NSURL* shorebirdYamlPath = [NSURL URLWithString:@"shorebird.yaml" + relativeToURL:[NSURL fileURLWithPath:assetsPath]]; + NSString* shorebirdYamlContents = [NSString stringWithContentsOfURL:shorebirdYamlPath + encoding:NSUTF8StringEncoding + error:nil]; + NSString* appVersion = + [NSBundle.mainBundle objectForInfoDictionaryKey:@"CFBundleShortVersionString"]; + NSString* appBuildNumber = [NSBundle.mainBundle objectForInfoDictionaryKey:@"CFBundleVersion"]; + std::string cache_path = + fml::paths::JoinPaths({getenv("HOME"), "Library", "Application Support", "shorebird"}); + flutter::ReleaseVersion release_version = {appVersion.UTF8String, appBuildNumber.UTF8String}; + flutter::ShorebirdConfigArgs shorebird_args(cache_path, cache_path, bundlePath.UTF8String, + shorebirdYamlContents.UTF8String, release_version); + NSLog(@"[shorebird] calling ConfigureShorebird"); + std::string patch_path; + auto res = flutter::ConfigureShorebird(shorebird_args, patch_path); + if (!res) { + NSLog(@"[shorebird] ConfigureShorebird failed"); + return NO; + } + + NSLog(@"[shorebird] ConfigureShorebird success!"); + *patchPath = [NSString stringWithUTF8String:patch_path.c_str()]; + NSLog(@"[shorebird] patchPath: %@", *patchPath); + return YES; +} + - (BOOL)runWithEntrypoint:(NSString*)entrypoint { if (self.running) { return NO; @@ -753,7 +789,19 @@ - (BOOL)runWithEntrypoint:(NSString*)entrypoint { }; flutterArguments.custom_task_runners = &custom_task_runners; - [self loadAOTData:_project.assetsPath]; + NSString* elfPath; + BOOL configureShorebirdRes = [self configureShorebird:&elfPath]; + if (!configureShorebirdRes) { + // No patch exists, or we failed to configure shorebird. This is a fallback. + // Upstream, this code lives in -(void)loadAOTData:. + // + // This is the location where the test fixture places the snapshot file. + // For applications built by Flutter tool, this is in "App.framework". + elfPath = [NSString pathWithComponents:@[ _project.assetsPath, @"app_elf_snapshot.so" ]]; + } + + [self loadAOTData:elfPath]; + if (_aotData) { flutterArguments.aot_data = _aotData; } @@ -777,6 +825,7 @@ - (BOOL)runWithEntrypoint:(NSString*)entrypoint { }; FlutterRendererConfig rendererConfig = [_renderer createRendererConfig]; + FlutterEngineResult result = _embedderAPI.Initialize( FLUTTER_ENGINE_VERSION, &rendererConfig, &flutterArguments, (__bridge void*)(self), &_engine); if (result != kSuccess) { @@ -806,7 +855,7 @@ - (BOOL)runWithEntrypoint:(NSString*)entrypoint { return YES; } -- (void)loadAOTData:(NSString*)assetsDir { +- (void)loadAOTData:(NSString*)elfPath { if (!_embedderAPI.RunsAOTCompiledDartCode()) { return; } @@ -814,11 +863,8 @@ - (void)loadAOTData:(NSString*)assetsDir { BOOL isDirOut = false; // required for NSFileManager fileExistsAtPath. NSFileManager* fileManager = [NSFileManager defaultManager]; - // This is the location where the test fixture places the snapshot file. - // For applications built by Flutter tool, this is in "App.framework". - NSString* elfPath = [NSString pathWithComponents:@[ assetsDir, @"app_elf_snapshot.so" ]]; - if (![fileManager fileExistsAtPath:elfPath isDirectory:&isDirOut]) { + FML_LOG(INFO) << "in loadAOTData, elfPath does not exist: " << elfPath.UTF8String; return; } diff --git a/engine/src/flutter/shell/platform/linux/BUILD.gn b/engine/src/flutter/shell/platform/linux/BUILD.gn index b9b1fb6657b11..2039603cb0330 100644 --- a/engine/src/flutter/shell/platform/linux/BUILD.gn +++ b/engine/src/flutter/shell/platform/linux/BUILD.gn @@ -156,6 +156,7 @@ source_set("flutter_linux_sources") { "fl_settings_channel.cc", "fl_settings_handler.cc", "fl_settings_portal.cc", + "fl_shorebird.cc", "fl_socket_accessible.cc", "fl_standard_message_codec.cc", "fl_standard_method_codec.cc", @@ -184,12 +185,14 @@ source_set("flutter_linux_sources") { deps = [ "$dart_src/runtime:dart_api", "//flutter/fml", + "//flutter/shell/common/shorebird", "//flutter/shell/platform/common:common_cpp_enums", "//flutter/shell/platform/common:common_cpp_input", "//flutter/shell/platform/common:common_cpp_isolate_scope", "//flutter/shell/platform/common:common_cpp_switches", "//flutter/shell/platform/embedder:embedder_headers", "//flutter/third_party/rapidjson", + "//flutter/third_party/tonic", ] } diff --git a/engine/src/flutter/shell/platform/linux/fl_engine.cc b/engine/src/flutter/shell/platform/linux/fl_engine.cc index c1205d9974536..abb1701c3a95d 100644 --- a/engine/src/flutter/shell/platform/linux/fl_engine.cc +++ b/engine/src/flutter/shell/platform/linux/fl_engine.cc @@ -10,6 +10,7 @@ #include #include "flutter/common/constants.h" +#include "flutter/fml/logging.h" #include "flutter/shell/platform/common/engine_switches.h" #include "flutter/shell/platform/embedder/embedder.h" #include "flutter/shell/platform/linux/fl_accessibility_handler.h" @@ -24,6 +25,7 @@ #include "flutter/shell/platform/linux/fl_platform_handler.h" #include "flutter/shell/platform/linux/fl_plugin_registrar_private.h" #include "flutter/shell/platform/linux/fl_settings_handler.h" +#include "flutter/shell/platform/linux/fl_shorebird.h" #include "flutter/shell/platform/linux/fl_texture_gl_private.h" #include "flutter/shell/platform/linux/fl_texture_registrar_private.h" #include "flutter/shell/platform/linux/public/flutter_linux/fl_plugin_registry.h" @@ -781,6 +783,9 @@ gboolean fl_engine_start(FlEngine* self, GError** error) { g_autoptr(GPtrArray) command_line_args = g_ptr_array_new_with_free_func(g_free); + // FlutterProjectArgs expects a full argv, so when processing it for flags + // the first item is treated as the executable and ignored. Add a dummy + // value so that all switches are used. g_ptr_array_insert(command_line_args, 0, g_strdup("flutter")); for (const auto& env_switch : flutter::GetSwitchesFromEnvironment()) { g_ptr_array_add(command_line_args, g_strdup(env_switch.c_str())); @@ -818,9 +823,22 @@ gboolean fl_engine_start(FlEngine* self, GError** error) { args.compositor = &compositor; if (self->embedder_api.RunsAOTCompiledDartCode()) { + // This struct contains raw C strings and needs to have its lifetime scoped + // to this block. FlutterEngineAOTDataSource source = {}; source.type = kFlutterEngineAOTDataSourceTypeElfPath; - source.elf_path = fl_dart_project_get_aot_library_path(self->project); + std::string patch_path; + auto setup_shorebird_result = + flutter::SetUpShorebird(args.assets_path, patch_path); + if (setup_shorebird_result) { + // If we have a patch installed, we replace the default AOT library path + // with the patch path here. + source.elf_path = patch_path.c_str(); + } else { + FML_LOG(ERROR) << "Failed to configure Shorebird."; + source.elf_path = fl_dart_project_get_aot_library_path(self->project); + } + if (self->embedder_api.CreateAOTData(&source, &self->aot_data) != kSuccess) { g_set_error(error, fl_engine_error_quark(), FL_ENGINE_ERROR_FAILED, diff --git a/engine/src/flutter/shell/platform/linux/fl_shorebird.cc b/engine/src/flutter/shell/platform/linux/fl_shorebird.cc new file mode 100644 index 0000000000000..eb913ad720c61 --- /dev/null +++ b/engine/src/flutter/shell/platform/linux/fl_shorebird.cc @@ -0,0 +1,56 @@ +#include "flutter/shell/platform/linux/fl_shorebird.h" + +#include +#include +#include + +#include "flutter/fml/file.h" +#include "flutter/fml/logging.h" +#include "flutter/fml/paths.h" +#include "flutter/shell/common/shorebird/shorebird.h" +#include "rapidjson/document.h" +#include "third_party/tonic/filesystem/filesystem/file.h" + +// Namespaced to avoid Google style warnings. +namespace flutter { + +gboolean SetUpShorebird(const char* assets_path, std::string& patch_path) { + auto shorebird_yaml_path = + fml::paths::JoinPaths({assets_path, "shorebird.yaml"}); + std::string shorebird_yaml_contents(""); + if (!filesystem::ReadFileToString(shorebird_yaml_path, + &shorebird_yaml_contents)) { + FML_LOG(ERROR) << "Failed to read shorebird.yaml."; + return false; + } + + std::string code_cache_path = + fml::paths::JoinPaths({g_get_home_dir(), ".shorebird_cache"}); + auto executable_location = fml::paths::GetExecutableDirectoryPath().second; + auto app_path = + fml::paths::JoinPaths({executable_location, "lib", "libapp.so"}); + auto version_json_path = fml::paths::JoinPaths({assets_path, "version.json"}); + std::ifstream input(version_json_path); + if (!input) { + return false; + } + std::string json_contents{std::istreambuf_iterator(input), + std::istreambuf_iterator()}; + + rapidjson::Document json_doc; + json_doc.Parse(json_contents.c_str()); + if (json_doc.HasParseError()) { + // Could not parse version file, aborting. + return false; + } + + const auto version_map = json_doc.GetObject(); + ReleaseVersion release_version{version_map["version"].GetString(), + version_map["build_number"].GetString()}; + + ShorebirdConfigArgs shorebird_args(code_cache_path, code_cache_path, app_path, + shorebird_yaml_contents, release_version); + return ConfigureShorebird(shorebird_args, patch_path); +} + +} // namespace flutter diff --git a/engine/src/flutter/shell/platform/linux/fl_shorebird.h b/engine/src/flutter/shell/platform/linux/fl_shorebird.h new file mode 100644 index 0000000000000..e38965776ac97 --- /dev/null +++ b/engine/src/flutter/shell/platform/linux/fl_shorebird.h @@ -0,0 +1,13 @@ +#ifndef FLUTTER_SHELL_PLATFORM_LINUX_FL_SHOREBIRD_H_ +#define FLUTTER_SHELL_PLATFORM_LINUX_FL_SHOREBIRD_H_ + +#include +#include + +namespace flutter { + +gboolean SetUpShorebird(const char* assets_path, std::string& patch_path); + +} // namespace flutter + +#endif // FLUTTER_SHELL_PLATFORM_LINUX_FL_SHOREBIRD_H_ diff --git a/engine/src/flutter/shell/platform/windows/BUILD.gn b/engine/src/flutter/shell/platform/windows/BUILD.gn index 6c547936faa0c..52690485316d2 100644 --- a/engine/src/flutter/shell/platform/windows/BUILD.gn +++ b/engine/src/flutter/shell/platform/windows/BUILD.gn @@ -169,6 +169,7 @@ source_set("flutter_windows_source") { ":flutter_windows_headers", "//flutter/fml", "//flutter/impeller/renderer/backend/gles", + "//flutter/shell/common/shorebird", "//flutter/shell/geometry", "//flutter/shell/platform/common:common_cpp", "//flutter/shell/platform/common:common_cpp_input", @@ -184,6 +185,7 @@ source_set("flutter_windows_source") { "//flutter/third_party/angle:libEGL_static", "//flutter/third_party/angle:libGLESv2_static", "//flutter/third_party/rapidjson", + "//flutter/third_party/tonic", ] } @@ -195,6 +197,11 @@ copy("publish_headers_windows") { deps = [ "//flutter/shell/platform/common:publish_headers" ] } +copy("updater_exports_windows") { + sources = [ "flutter_windows.dll.def" ] + outputs = [ "$root_out_dir/{{source_file_part}}" ] +} + shared_library("flutter_windows") { deps = [ ":flutter_windows_source" ] @@ -310,6 +317,7 @@ group("windows") { deps = [ ":flutter_windows", ":publish_headers_windows", + ":updater_exports_windows", "//flutter/shell/platform/windows/client_wrapper:publish_wrapper_windows", ] diff --git a/engine/src/flutter/shell/platform/windows/flutter_project_bundle.h b/engine/src/flutter/shell/platform/windows/flutter_project_bundle.h index 4291206c920e0..e95f4780d88b7 100644 --- a/engine/src/flutter/shell/platform/windows/flutter_project_bundle.h +++ b/engine/src/flutter/shell/platform/windows/flutter_project_bundle.h @@ -56,6 +56,10 @@ class FlutterProjectBundle { // Sets engine switches. void SetSwitches(const std::vector& switches); + void SetAotLibraryPath(const std::filesystem::path& aot_library_path) { + aot_library_path_ = aot_library_path; + } + // Attempts to load AOT data for this bundle. The returned data must be // retained until any engine instance it is passed to has been shut down. // diff --git a/engine/src/flutter/shell/platform/windows/flutter_windows.dll.def b/engine/src/flutter/shell/platform/windows/flutter_windows.dll.def new file mode 100644 index 0000000000000..bcaa785977259 --- /dev/null +++ b/engine/src/flutter/shell/platform/windows/flutter_windows.dll.def @@ -0,0 +1,17 @@ +EXPORTS + shorebird_check_for_downloadable_update = shorebird_check_for_downloadable_update + shorebird_check_for_update = shorebird_check_for_update + shorebird_current_boot_patch_number = shorebird_current_boot_patch_number + shorebird_free_string = shorebird_free_string + shorebird_free_update_result = shorebird_free_update_result + shorebird_init = shorebird_init + shorebird_next_boot_patch_number = shorebird_next_boot_patch_number + shorebird_next_boot_patch_path = shorebird_next_boot_patch_path + shorebird_validate_next_boot_patch = shorebird_validate_next_boot_patch + shorebird_report_launch_failure = shorebird_report_launch_failure + shorebird_report_launch_start = shorebird_report_launch_start + shorebird_report_launch_success = shorebird_report_launch_success + shorebird_should_auto_update = shorebird_should_auto_update + shorebird_start_update_thread = shorebird_start_update_thread + shorebird_update = shorebird_update + shorebird_update_with_result = shorebird_update_with_result \ No newline at end of file diff --git a/engine/src/flutter/shell/platform/windows/flutter_windows_engine.cc b/engine/src/flutter/shell/platform/windows/flutter_windows_engine.cc index 70d7364d56e64..22b028beb03f4 100644 --- a/engine/src/flutter/shell/platform/windows/flutter_windows_engine.cc +++ b/engine/src/flutter/shell/platform/windows/flutter_windows_engine.cc @@ -5,15 +5,20 @@ #include "flutter/shell/platform/windows/flutter_windows_engine.h" #include +#include +#include +#include #include #include #include +#include #include "flutter/fml/logging.h" #include "flutter/fml/paths.h" #include "flutter/fml/platform/win/wstring_conversion.h" #include "flutter/fml/synchronization/waitable_event.h" +#include "flutter/shell/common/shorebird/shorebird.h" #include "flutter/shell/platform/common/client_wrapper/binary_messenger_impl.h" #include "flutter/shell/platform/common/client_wrapper/include/flutter/standard_message_codec.h" #include "flutter/shell/platform/common/path_utils.h" @@ -29,6 +34,7 @@ #include "flutter/shell/platform/windows/window_manager.h" #include "flutter/third_party/accessibility/ax/ax_node.h" #include "shell/platform/windows/flutter_project_bundle.h" +#include "third_party/tonic/filesystem/filesystem/file.h" // winbase.h defines GetCurrentTime as a macro. #undef GetCurrentTime @@ -269,6 +275,125 @@ bool FlutterWindowsEngine::Run() { return Run(""); } +int GetReleaseVersionAndBuildNumber(ReleaseVersion* release_version) { + char module_path[MAX_PATH]; + // Get the full path of the currently running executable. The return value is + // the size of the string that was copied to the buffer, with -1 indicating + // failure. + if (GetModuleFileNameA(NULL, module_path, MAX_PATH) == -1) { + return -1; + } + + // Get the size of the version information + DWORD handle = -1; + DWORD version_info_size = GetFileVersionInfoSizeA(module_path, &handle); + if (version_info_size == -1) { + return -1; + } + + // Allocate memory for version info + std::unique_ptr version_data(new char[version_info_size]); + if (!GetFileVersionInfoA(module_path, handle, version_info_size, + version_data.get())) { + return -1; + } + + // Adopted from + // https://learn.microsoft.com/en-us/windows/win32/api/winver/nf-winver-verqueryvaluea + // Get the translation table + struct LANGANDCODEPAGE { + WORD wLanguage; + WORD wCodePage; + }* lpTranslate; + + UINT cbTranslate = 0; + if (!VerQueryValueA(version_data.get(), "\\VarFileInfo\\Translation", + (LPVOID*)&lpTranslate, &cbTranslate)) { + FML_LOG(ERROR) << "Error: Unable to get translation info."; + return -1; + } + + // Construct the query string using the first translation found + char subBlock[64]; + sprintf_s(subBlock, "\\StringFileInfo\\%04x%04x\\ProductVersion", + lpTranslate[0].wLanguage, lpTranslate[0].wCodePage); + + LPSTR versionString = nullptr; + UINT size = 0; + if (!VerQueryValueA(version_data.get(), subBlock, (LPVOID*)&versionString, + &size)) { + return -1; + } + + if (!versionString) { + return -1; + } + + // The version string is in the format of "1.0.0+1", with the label ("+1") + // being optional. + auto version = std::string(versionString); + auto plusPos = version.find("+"); + if (plusPos != std::string::npos) { + auto semVer = version.substr(0, plusPos); + auto patch = version.substr(plusPos + 1, version.length()); + release_version->version = semVer; + release_version->build_number = patch; + } else { + release_version->version = version; + } + + return kSuccess; +} + +bool GetLocalAppDataPath(std::string& outPath) { + PWSTR path = nullptr; + HRESULT result = SHGetKnownFolderPath(FOLDERID_LocalAppData, 0, NULL, &path); + if (!SUCCEEDED(result)) { + return false; + } + + std::wstring widePath(path); + std::string localAppDataPath(widePath.begin(), widePath.end()); + // The calling process is responsible for freeing this resource + // https://learn.microsoft.com/en-us/windows/win32/api/shlobj_core/nf-shlobj_core-shgetknownfolderpath + CoTaskMemFree(path); + outPath = localAppDataPath; + return true; +} + +bool SetUpShorebird(std::string assets_path_string, std::string& patch_path) { + auto shorebird_yaml_path = + fml::paths::JoinPaths({assets_path_string, "shorebird.yaml"}); + std::string shorebird_yaml_contents(""); + if (!filesystem::ReadFileToString(shorebird_yaml_path, + &shorebird_yaml_contents)) { + FML_LOG(ERROR) << "Failed to read shorebird.yaml."; + return false; + } + + std::string code_cache_path; + if (!GetLocalAppDataPath(code_cache_path)) { + FML_LOG(ERROR) << "Failed to retrieve the local AppData directory."; + return false; + } + + auto executable_location = fml::paths::GetExecutableDirectoryPath().second; + auto app_path = + fml::paths::JoinPaths({executable_location, "data", "app.so"}); + ReleaseVersion release_version; + auto release_version_result = + GetReleaseVersionAndBuildNumber(&release_version); + if (release_version_result != kSuccess) { + FML_LOG(ERROR) + << "Failed to retrieve the release version and build number."; + return false; + } + + ShorebirdConfigArgs shorebird_args(code_cache_path, code_cache_path, app_path, + shorebird_yaml_contents, release_version); + return ConfigureShorebird(shorebird_args, patch_path); +} + bool FlutterWindowsEngine::Run(std::string_view entrypoint) { if (!project_->HasValidPaths()) { FML_LOG(ERROR) << "Missing or unresolvable paths to assets."; @@ -276,6 +401,19 @@ bool FlutterWindowsEngine::Run(std::string_view entrypoint) { } std::string assets_path_string = fml::PathToUtf8(project_->assets_path()); std::string icu_path_string = fml::PathToUtf8(project_->icu_path()); + + std::string patch_path; + auto setup_shorebird_result = SetUpShorebird(assets_path_string, patch_path); + if (setup_shorebird_result) { + // If we have a patch installed, we replace the default AOT library path + // with the patch path here. + FML_LOG(INFO) << "Setting project patch path: " << patch_path; + project_->SetAotLibraryPath(patch_path); + } else { + FML_LOG(ERROR) << "Failed to configure Shorebird."; + } + + // This loads AOT data from the project_'s aot_library_path_. if (embedder_api_.RunsAOTCompiledDartCode()) { aot_data_ = project_->LoadAotData(embedder_api_); if (!aot_data_) { @@ -406,6 +544,15 @@ bool FlutterWindowsEngine::Run(std::string_view entrypoint) { host->root_isolate_create_callback_(); } }; + // Copied from shell\platform\darwin\macos\framework\Source\FlutterEngine.mm + // Writes log messages to stdout. + args.log_message_callback = [](const char* tag, const char* message, + void* user_data) { + if (tag && tag[0]) { + std::cout << tag << ": "; + } + std::cout << message << std::endl; + }; args.channel_update_callback = [](const FlutterChannelUpdate* update, void* user_data) { auto host = static_cast(user_data); diff --git a/engine/src/flutter/shell/platform/windows/task_runner.cc b/engine/src/flutter/shell/platform/windows/task_runner.cc index fc4b541abf801..266e7b6738c0b 100644 --- a/engine/src/flutter/shell/platform/windows/task_runner.cc +++ b/engine/src/flutter/shell/platform/windows/task_runner.cc @@ -63,10 +63,11 @@ std::chrono::nanoseconds TaskRunner::ProcessTasks() { // Calculate duration to sleep for on next iteration. { std::lock_guard lock(task_queue_mutex_); - const auto next_wake = task_queue_.empty() ? TaskTimePoint::max() - : task_queue_.top().fire_time; - - return std::min(next_wake - now, std::chrono::nanoseconds::max()); + if (task_queue_.empty()) { + return std::chrono::nanoseconds::max(); + } else { + return task_queue_.top().fire_time - now; + } } } diff --git a/engine/src/flutter/shell/platform/windows/task_runner_unittests.cc b/engine/src/flutter/shell/platform/windows/task_runner_unittests.cc index fce19ae35851b..d0b049d2bf4a4 100644 --- a/engine/src/flutter/shell/platform/windows/task_runner_unittests.cc +++ b/engine/src/flutter/shell/platform/windows/task_runner_unittests.cc @@ -121,6 +121,12 @@ class TestTaskRunnerWindow : public TaskRunnerWindow { TestTaskRunnerWindow() : TaskRunnerWindow() {} }; +TEST(TaskRunnerTest, EmptyTaskRunnerReturnsNanoSecondsMax) { + TaskRunner runner(MockGetCurrentTime, [](const FlutterTask*) {}); + auto res = runner.ProcessTasks(); + EXPECT_EQ(res, std::chrono::nanoseconds::max()); +} + TEST(TaskRunnerTest, TaskRunnerWindowCoalescesWakeUpMessages) { class Delegate : public TaskRunnerWindow::Delegate { public: diff --git a/engine/src/flutter/shell/platform/windows/task_runner_window.cc b/engine/src/flutter/shell/platform/windows/task_runner_window.cc index d094c3c36bb96..08b69a6d20f6a 100644 --- a/engine/src/flutter/shell/platform/windows/task_runner_window.cc +++ b/engine/src/flutter/shell/platform/windows/task_runner_window.cc @@ -185,12 +185,11 @@ void TaskRunnerWindow::ProcessTasks() { } void TaskRunnerWindow::SetTimer(std::chrono::nanoseconds when) { - if (when == std::chrono::nanoseconds::max()) { - timer_thread_.ScheduleAt( - std::chrono::time_point::max()); - } else { - timer_thread_.ScheduleAt(std::chrono::high_resolution_clock::now() + when); - } + auto now = std::chrono::high_resolution_clock::now(); + auto remaining_to_max = + std::chrono::nanoseconds::max() - now.time_since_epoch(); + when = std::min(when, remaining_to_max); + timer_thread_.ScheduleAt(now + when); } WNDCLASS TaskRunnerWindow::RegisterWindowClass() { diff --git a/engine/src/flutter/shell/testing/BUILD.gn b/engine/src/flutter/shell/testing/BUILD.gn index 79dd5084ffee2..0ed88d93ed52a 100644 --- a/engine/src/flutter/shell/testing/BUILD.gn +++ b/engine/src/flutter/shell/testing/BUILD.gn @@ -42,6 +42,7 @@ executable("testing") { deps = [ "$dart_src/runtime:libdart_jit", "$dart_src/runtime/bin:common_embedder_dart_io", + "$dart_src/runtime/bin:elf_loader", "//flutter/assets", "//flutter/common", "//flutter/flow", diff --git a/engine/src/flutter/sky/packages/sky_engine/LICENSE b/engine/src/flutter/sky/packages/sky_engine/LICENSE index bb3a00d46a193..7271a8941a956 100644 --- a/engine/src/flutter/sky/packages/sky_engine/LICENSE +++ b/engine/src/flutter/sky/packages/sky_engine/LICENSE @@ -21353,8 +21353,8 @@ libpng PNG Reference Library License version 2 --------------------------------------- - * Copyright (c) 1995-2024 The PNG Reference Library Authors. - * Copyright (c) 2018-2024 Cosmin Truta. + * Copyright (c) 1995-2025 The PNG Reference Library Authors. + * Copyright (c) 2018-2025 Cosmin Truta. * Copyright (c) 2000-2002, 2004, 2006-2018 Glenn Randers-Pehrson. * Copyright (c) 1996-1997 Andreas Dilger. * Copyright (c) 1995-1996 Guy Eric Schalnat, Group 42, Inc. @@ -21488,8 +21488,8 @@ libpng PNG Reference Library License version 2 --------------------------------------- - * Copyright (c) 1995-2024 The PNG Reference Library Authors. - * Copyright (c) 2018-2024 Cosmin Truta. + * Copyright (c) 1995-2025 The PNG Reference Library Authors. + * Copyright (c) 2018-2025 Cosmin Truta. * Copyright (c) 2000-2002, 2004, 2006-2018 Glenn Randers-Pehrson. * Copyright (c) 1996-1997 Andreas Dilger. * Copyright (c) 1995-1996 Guy Eric Schalnat, Group 42, Inc. diff --git a/engine/src/flutter/sky/tools/create_ios_framework.py b/engine/src/flutter/sky/tools/create_ios_framework.py index d87e0f91bff49..0ead267e9609e 100644 --- a/engine/src/flutter/sky/tools/create_ios_framework.py +++ b/engine/src/flutter/sky/tools/create_ios_framework.py @@ -19,7 +19,8 @@ def main(): parser = argparse.ArgumentParser( description=( 'Creates Flutter.framework, Flutter.xcframework and ' - 'copies architecture-dependent gen_snapshot binaries to output dir' + 'copies architecture-dependent analyze_snapshot and gen_snapshot ' + 'binaries to output dir' ) ) @@ -89,13 +90,17 @@ def main(): '%s_extension_safe' % simulator_x64_out_dir, '%s_extension_safe' % simulator_arm64_out_dir ) - # Copy gen_snapshot binary to destination directory. + # Copy analyze_snapshot and gen_snapshot binaries to destination directory. if arm64_out_dir: gen_snapshot = os.path.join(arm64_out_dir, 'universal', 'gen_snapshot_arm64') + analyze_snapshot = os.path.join(arm64_out_dir, 'analyze_snapshot_arm64') sky_utils.copy_binary(gen_snapshot, os.path.join(dst, 'gen_snapshot_arm64')) + sky_utils.copy_binary(analyze_snapshot, os.path.join(dst, 'analyze_snapshot_arm64')) if x64_out_dir: gen_snapshot = os.path.join(x64_out_dir, 'universal', 'gen_snapshot_x64') + analyze_snapshot = os.path.join(x64_out_dir, 'analyze_snapshot_x64') sky_utils.copy_binary(gen_snapshot, os.path.join(dst, 'gen_snapshot_x64')) + sky_utils.copy_binary(analyze_snapshot, os.path.join(dst, 'analyze_snapshot_x64')) zip_archive(dst, args) return 0 @@ -177,7 +182,7 @@ def zip_archive(dst, args): # See: https://github.com/flutter/flutter/blob/62382c7b83a16b3f48dc06c19a47f6b8667005a5/dev/bots/suite_runners/run_verify_binaries_codesigned_tests.dart#L82-L130 # Binaries that must be codesigned and require entitlements for particular APIs. - with_entitlements = ['gen_snapshot_arm64'] + with_entitlements = ['analyze_snapshot_arm64', 'gen_snapshot_arm64'] with_entitlements_file = os.path.join(dst, 'entitlements.txt') sky_utils.write_codesign_config(with_entitlements_file, with_entitlements) @@ -211,6 +216,7 @@ def zip_archive(dst, args): # pylint: enable=line-too-long zip_contents = [ + 'analyze_snapshot_arm64', 'gen_snapshot_arm64', 'Flutter.xcframework', 'entitlements.txt', diff --git a/engine/src/flutter/testing/ios_scenario_app/README.md b/engine/src/flutter/testing/ios_scenario_app/README.md index 4efafa00beebe..f12654ea5c935 100644 --- a/engine/src/flutter/testing/ios_scenario_app/README.md +++ b/engine/src/flutter/testing/ios_scenario_app/README.md @@ -18,11 +18,26 @@ See also: ## Adding a New Scenario -Create a new subclass of [Scenario](lib/src/scenario.dart) and add it to the map -in [scenarios.dart](lib/src/scenarios.dart). For an example, see +Like a regular Flutter iOS app, the Scenario app consists of the [iOS embedding +code](ios/Scenarios/Scenarios/AppDelegate.m) and the dart logic that are +`Scenario`s. + +To introduce a new subclass of [Scenario](lib/src/scenario.dart), add it to the map +in [scenarios.dart](lib/src/scenarios.dart). For an example, [animated_color_square.dart](lib/src/animated_color_square.dart), which draws a continuously animating colored square that bounces off the sides of the viewport. -Then set the scenario from the iOS app by calling `set_scenario` on platform -channel `driver`. +The Scenarios app loads a `Scenario` when it receives a `set_scenario` method call +on the `driver` platform channel from the objective-c code. However if you're +adding a UI test this is typically not needed as you typically should add a new +launch argument. See +[ScenariosUITests](ios/Scenarios/ScenariosUITests/README.md) for more details. + +## Running a specific test + +The `run_ios_tests.sh` script runs all tests in the `Scenarios` project. If you're +debugging a specific test, rebuild the `ios_debug_sim_unopt_arm64` engine variant +(assuming testing on a simulator on Apple Silicon chips), and open +`src/out/ios_debug_sim_unopt_arm64/ios_scenario_app/Scenarios.xcworkspace` in xcode. +Use the xcode UI to run the test. diff --git a/engine/src/flutter/testing/ios_scenario_app/ios/Scenarios/Scenarios.xcodeproj/xcshareddata/xcschemes/Scenarios.xcscheme b/engine/src/flutter/testing/ios_scenario_app/ios/Scenarios/Scenarios.xcodeproj/xcshareddata/xcschemes/Scenarios.xcscheme index dcd0cd0a356f0..fa70fe5069d25 100644 --- a/engine/src/flutter/testing/ios_scenario_app/ios/Scenarios/Scenarios.xcodeproj/xcshareddata/xcschemes/Scenarios.xcscheme +++ b/engine/src/flutter/testing/ios_scenario_app/ios/Scenarios/Scenarios.xcodeproj/xcshareddata/xcschemes/Scenarios.xcscheme @@ -77,6 +77,10 @@ argument = "--screen-before-flutter" isEnabled = "NO"> + + diff --git a/engine/src/flutter/testing/ios_scenario_app/ios/Scenarios/Scenarios/AppDelegate.m b/engine/src/flutter/testing/ios_scenario_app/ios/Scenarios/Scenarios/AppDelegate.m index 60d7d35f46016..41d032c6d8f1d 100644 --- a/engine/src/flutter/testing/ios_scenario_app/ios/Scenarios/Scenarios/AppDelegate.m +++ b/engine/src/flutter/testing/ios_scenario_app/ios/Scenarios/Scenarios/AppDelegate.m @@ -16,6 +16,10 @@ @interface NoStatusBarViewController : UIViewController @end +@interface FlutterEngine () +@property(nonatomic, strong) FlutterMethodChannel* statusBarChannel; +@end + @implementation NoStatusBarViewController - (BOOL)prefersStatusBarHidden { return YES; @@ -210,11 +214,28 @@ - (void)setupFlutterViewControllerTest:(NSString*)scenarioIdentifier { FlutterPlatformViewGestureRecognizersBlockingPolicyWaitUntilTouchesEnded]; UIViewController* rootViewController = flutterViewController; - // Make Flutter View's origin x/y not 0. if ([scenarioIdentifier isEqualToString:@"non_full_screen_flutter_view_platform_view"]) { + // Make Flutter View's origin x/y not 0. rootViewController = [[NoStatusBarViewController alloc] init]; [rootViewController.view addSubview:flutterViewController.view]; flutterViewController.view.frame = CGRectMake(150, 150, 500, 500); + } else if ([scenarioIdentifier isEqualToString:@"tap_status_bar"]) { + [engine.binaryMessenger + setMessageHandlerOnChannel:@"flutter/status_bar" + binaryMessageHandler:^(NSData* _Nullable message, FlutterBinaryReply _Nonnull reply) { + NSDictionary* dict = [NSJSONSerialization JSONObjectWithData:message + options:0 + error:nil]; + FlutterBasicMessageChannel* channel = [[FlutterBasicMessageChannel alloc] + initWithName:@"display_data" + binaryMessenger:engine.binaryMessenger + codec:[FlutterJSONMessageCodec sharedInstance]]; + [channel sendMessage:@{@"data" : dict}]; + UITextField* text = + [[UITextField alloc] initWithFrame:CGRectMake(0, 400, 300, 100)]; + text.text = dict[@"method"]; + [flutterViewController.view addSubview:text]; + }]; } self.window.rootViewController = rootViewController; diff --git a/engine/src/flutter/testing/ios_scenario_app/ios/Scenarios/ScenariosUITests/README.md b/engine/src/flutter/testing/ios_scenario_app/ios/Scenarios/ScenariosUITests/README.md index 652fbed14aa35..9e6d9a25942b0 100644 --- a/engine/src/flutter/testing/ios_scenario_app/ios/Scenarios/ScenariosUITests/README.md +++ b/engine/src/flutter/testing/ios_scenario_app/ios/Scenarios/ScenariosUITests/README.md @@ -1,3 +1,19 @@ +# Adding a New Scenario for a XCUITest + +An XCUITest is different from a regular XCTest in that the test subject app runs +in a different process than the test suite, making it trickier for the test code +to communicate with the app. For instance, you won't have access to +the view controller or engine instances from within the test code. + +For this reason, the test code typically uses **launch arguments** to configure +the app (for example, use [launchArgsMap](../Scenarios/AppDelegate.m) to inform +the app which `Scenario` to load), and use UIKit UI components to collect test +results (for example, every messsage received on the `display_data` channel adds +a new `UITextField` to the app, which will be visible to the test code. See [touches_scenario.dart](../../../lib/src/touches_scenario.dart) for an example). + +Refer to the [Adding a New Scenario](./../../../README.md) section for how to +register a new dart `Scenario`. + # Golden UI Tests This folder contains golden image tests. It renders UI (for instance, a platform @@ -17,3 +33,8 @@ indicating the file name it expected to find. The test will continue and fail, but will contain an attachment with the expected screen shot. If the screen shot looks good, add it with the correct name to the project and run the test again - it should pass this time. + +## Running a specific Scenario + +Add and enable the new launch argument to the `Arguments Passed On Launch` +section of the `Debug - Run` scheme, and build and run the app. diff --git a/engine/src/flutter/testing/ios_scenario_app/ios/Scenarios/ScenariosUITests/StatusBarTest.m b/engine/src/flutter/testing/ios_scenario_app/ios/Scenarios/ScenariosUITests/StatusBarTest.m index aee1a63a239e9..dd1b3263be2f7 100644 --- a/engine/src/flutter/testing/ios_scenario_app/ios/Scenarios/ScenariosUITests/StatusBarTest.m +++ b/engine/src/flutter/testing/ios_scenario_app/ios/Scenarios/ScenariosUITests/StatusBarTest.m @@ -16,6 +16,10 @@ - (void)setUp { } - (void)testTapStatusBar { + XCUIElement* textField = self.application.textFields[@"handleScrollToTop"]; + BOOL exists = [textField waitForExistenceWithTimeout:1]; + XCTAssertFalse(exists, @""); + XCUIApplication* systemApp = [[XCUIApplication alloc] initWithBundleIdentifier:@"com.apple.springboard"]; XCUIElement* statusBar = [systemApp.statusBars firstMatch]; @@ -25,21 +29,7 @@ - (void)testTapStatusBar { XCUICoordinate* coordinates = [statusBar coordinateWithNormalizedOffset:CGVectorMake(0, 0)]; [coordinates tap]; } - - XCUIElement* addTextField = - self.application - .textFields[@"0,PointerChange.add,device=0,buttons=0,signalKind=PointerSignalKind.none"]; - BOOL exists = [addTextField waitForExistenceWithTimeout:1]; - XCTAssertTrue(exists, @""); - XCUIElement* downTextField = - self.application - .textFields[@"1,PointerChange.down,device=0,buttons=0,signalKind=PointerSignalKind.none"]; - exists = [downTextField waitForExistenceWithTimeout:1]; - XCTAssertTrue(exists, @""); - XCUIElement* upTextField = - self.application - .textFields[@"2,PointerChange.up,device=0,buttons=0,signalKind=PointerSignalKind.none"]; - exists = [upTextField waitForExistenceWithTimeout:1]; + exists = [textField waitForExistenceWithTimeout:1]; XCTAssertTrue(exists, @""); } diff --git a/engine/src/flutter/testing/ios_scenario_app/ios/Scenarios/ScenariosUITests/golden_platform_view_cliprect_after_moved_impeller_iPhone SE (3rd generation)_18.2_simulator.png b/engine/src/flutter/testing/ios_scenario_app/ios/Scenarios/ScenariosUITests/golden_platform_view_cliprect_after_moved_impeller_iPhone SE (3rd generation)_18.2_simulator.png index 087dd0c92ec0f..3843c1796f34b 100644 Binary files a/engine/src/flutter/testing/ios_scenario_app/ios/Scenarios/ScenariosUITests/golden_platform_view_cliprect_after_moved_impeller_iPhone SE (3rd generation)_18.2_simulator.png and b/engine/src/flutter/testing/ios_scenario_app/ios/Scenarios/ScenariosUITests/golden_platform_view_cliprect_after_moved_impeller_iPhone SE (3rd generation)_18.2_simulator.png differ diff --git a/engine/src/flutter/testing/ios_scenario_app/ios/Scenarios/ScenariosUITests/golden_platform_view_cliprect_after_moved_multiple_clips_impeller_iPhone SE (3rd generation)_18.2_simulator.png b/engine/src/flutter/testing/ios_scenario_app/ios/Scenarios/ScenariosUITests/golden_platform_view_cliprect_after_moved_multiple_clips_impeller_iPhone SE (3rd generation)_18.2_simulator.png index e5d34fd200706..2870aa165145c 100644 Binary files a/engine/src/flutter/testing/ios_scenario_app/ios/Scenarios/ScenariosUITests/golden_platform_view_cliprect_after_moved_multiple_clips_impeller_iPhone SE (3rd generation)_18.2_simulator.png and b/engine/src/flutter/testing/ios_scenario_app/ios/Scenarios/ScenariosUITests/golden_platform_view_cliprect_after_moved_multiple_clips_impeller_iPhone SE (3rd generation)_18.2_simulator.png differ diff --git a/engine/src/flutter/testing/ios_scenario_app/ios/Scenarios/ScenariosUITests/golden_platform_view_cliprrect_with_transform_impeller_iPhone SE (3rd generation)_18.2_simulator.png b/engine/src/flutter/testing/ios_scenario_app/ios/Scenarios/ScenariosUITests/golden_platform_view_cliprrect_with_transform_impeller_iPhone SE (3rd generation)_18.2_simulator.png index 30d9c628ad5c5..004cfc21a3eb5 100644 Binary files a/engine/src/flutter/testing/ios_scenario_app/ios/Scenarios/ScenariosUITests/golden_platform_view_cliprrect_with_transform_impeller_iPhone SE (3rd generation)_18.2_simulator.png and b/engine/src/flutter/testing/ios_scenario_app/ios/Scenarios/ScenariosUITests/golden_platform_view_cliprrect_with_transform_impeller_iPhone SE (3rd generation)_18.2_simulator.png differ diff --git a/engine/src/flutter/testing/ios_scenario_app/ios/Scenarios/ScenariosUITests/golden_platform_view_opacity_impeller_iPhone SE (3rd generation)_18.2_simulator.png b/engine/src/flutter/testing/ios_scenario_app/ios/Scenarios/ScenariosUITests/golden_platform_view_opacity_impeller_iPhone SE (3rd generation)_18.2_simulator.png index b2a0d226b07ed..d4ce8d8735f1b 100644 Binary files a/engine/src/flutter/testing/ios_scenario_app/ios/Scenarios/ScenariosUITests/golden_platform_view_opacity_impeller_iPhone SE (3rd generation)_18.2_simulator.png and b/engine/src/flutter/testing/ios_scenario_app/ios/Scenarios/ScenariosUITests/golden_platform_view_opacity_impeller_iPhone SE (3rd generation)_18.2_simulator.png differ diff --git a/engine/src/flutter/testing/ios_scenario_app/ios/Scenarios/ScenariosUITests/golden_platform_view_with_negative_backdrop_filter_impeller_iPhone SE (3rd generation)_18.2_simulator.png b/engine/src/flutter/testing/ios_scenario_app/ios/Scenarios/ScenariosUITests/golden_platform_view_with_negative_backdrop_filter_impeller_iPhone SE (3rd generation)_18.2_simulator.png index bf4e70686699e..bc32735baa926 100644 Binary files a/engine/src/flutter/testing/ios_scenario_app/ios/Scenarios/ScenariosUITests/golden_platform_view_with_negative_backdrop_filter_impeller_iPhone SE (3rd generation)_18.2_simulator.png and b/engine/src/flutter/testing/ios_scenario_app/ios/Scenarios/ScenariosUITests/golden_platform_view_with_negative_backdrop_filter_impeller_iPhone SE (3rd generation)_18.2_simulator.png differ diff --git a/engine/src/flutter/testing/ios_scenario_app/ios/Scenarios/ScenariosUITests/golden_platform_view_with_other_backdrop_filter_impeller_iPhone SE (3rd generation)_18.2_simulator.png b/engine/src/flutter/testing/ios_scenario_app/ios/Scenarios/ScenariosUITests/golden_platform_view_with_other_backdrop_filter_impeller_iPhone SE (3rd generation)_18.2_simulator.png index 4d8fb70a0e0de..095775e251728 100644 Binary files a/engine/src/flutter/testing/ios_scenario_app/ios/Scenarios/ScenariosUITests/golden_platform_view_with_other_backdrop_filter_impeller_iPhone SE (3rd generation)_18.2_simulator.png and b/engine/src/flutter/testing/ios_scenario_app/ios/Scenarios/ScenariosUITests/golden_platform_view_with_other_backdrop_filter_impeller_iPhone SE (3rd generation)_18.2_simulator.png differ diff --git a/engine/src/flutter/testing/ios_scenario_app/ios/Scenarios/ScenariosUITests/golden_two_platform_views_with_other_backdrop_filter_impeller_iPhone SE (3rd generation)_18.2_simulator.png b/engine/src/flutter/testing/ios_scenario_app/ios/Scenarios/ScenariosUITests/golden_two_platform_views_with_other_backdrop_filter_impeller_iPhone SE (3rd generation)_18.2_simulator.png index 9bcb24d3efd3f..bd4660b67cae4 100644 Binary files a/engine/src/flutter/testing/ios_scenario_app/ios/Scenarios/ScenariosUITests/golden_two_platform_views_with_other_backdrop_filter_impeller_iPhone SE (3rd generation)_18.2_simulator.png and b/engine/src/flutter/testing/ios_scenario_app/ios/Scenarios/ScenariosUITests/golden_two_platform_views_with_other_backdrop_filter_impeller_iPhone SE (3rd generation)_18.2_simulator.png differ diff --git a/engine/src/flutter/testing/ios_scenario_app/lib/src/platform_channel_echo.dart b/engine/src/flutter/testing/ios_scenario_app/lib/src/platform_channel_echo.dart new file mode 100644 index 0000000000000..67e8063a1a53c --- /dev/null +++ b/engine/src/flutter/testing/ios_scenario_app/lib/src/platform_channel_echo.dart @@ -0,0 +1,30 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:typed_data' show ByteData; +import 'dart:ui'; + +import 'scenario.dart'; + +/// A scenario which intercepts all messages on the given channel, and sends back +/// the same message to the engine on a channel with the same name. +class EchoPlatformChannelScenario extends Scenario { + /// Constructor for `EchoPlatformChannelScenario`. + EchoPlatformChannelScenario(super.view, {required this.channel}) { + channelBuffers.setListener(channel, _onHandlePlatformMessage); + } + + /// The name of the channel where all messages should be intercepted. + final String channel; + + void _onHandlePlatformMessage(ByteData? data, PlatformMessageResponseCallback _) { + view.platformDispatcher.sendPlatformMessage(channel, data, null); + } + + @override + void unmount() { + channelBuffers.clearListener(channel); + super.unmount(); + } +} diff --git a/engine/src/flutter/testing/ios_scenario_app/lib/src/scenarios.dart b/engine/src/flutter/testing/ios_scenario_app/lib/src/scenarios.dart index 8447a3470afa3..01ffcf3e1e525 100644 --- a/engine/src/flutter/testing/ios_scenario_app/lib/src/scenarios.dart +++ b/engine/src/flutter/testing/ios_scenario_app/lib/src/scenarios.dart @@ -11,6 +11,7 @@ import 'darwin_system_font.dart'; import 'get_bitmap_scenario.dart'; import 'initial_route_reply.dart'; import 'locale_initialization.dart'; +import 'platform_channel_echo.dart'; import 'platform_view.dart'; import 'poppable_screen.dart'; import 'scenario.dart'; @@ -141,7 +142,8 @@ Map _scenarios = { TwoPlatformViewClipPath(view, firstId: _viewId++, secondId: _viewId++), 'two_platform_view_clip_path_multiple_clips': (FlutterView view) => TwoPlatformViewClipPathMultipleClips(view, firstId: _viewId++, secondId: _viewId++), - 'tap_status_bar': (FlutterView view) => TouchesScenario(view), + 'tap_status_bar': (FlutterView view) => + EchoPlatformChannelScenario(view, channel: 'flutter/status_bar'), 'initial_route_reply': (FlutterView view) => InitialRouteReply(view), 'platform_view_with_continuous_texture': (FlutterView view) => PlatformViewWithContinuousTexture(view, id: _viewId++), diff --git a/engine/src/flutter/testing/resources/square.png b/engine/src/flutter/testing/resources/square.png index 78a8b668951d7..7b9e12354b877 100644 Binary files a/engine/src/flutter/testing/resources/square.png and b/engine/src/flutter/testing/resources/square.png differ diff --git a/engine/src/flutter/testing/run_tests.py b/engine/src/flutter/testing/run_tests.py index 3fa7a2c39d0e0..e5972c413462a 100755 --- a/engine/src/flutter/testing/run_tests.py +++ b/engine/src/flutter/testing/run_tests.py @@ -484,6 +484,7 @@ def make_test( make_test('platform_view_android_delegate_unittests'), # https://github.com/flutter/flutter/issues/36295 make_test('shell_unittests'), + make_test('shorebird_unittests'), ] if is_windows(): diff --git a/engine/src/tools/dart/create_updated_flutter_deps.py b/engine/src/tools/dart/create_updated_flutter_deps.py index 4b0d41a8c253a..c5f2ee2234605 100755 --- a/engine/src/tools/dart/create_updated_flutter_deps.py +++ b/engine/src/tools/dart/create_updated_flutter_deps.py @@ -12,6 +12,7 @@ import argparse import os +import re import sys DART_SCRIPT_DIR = os.path.dirname(sys.argv[0]) @@ -53,6 +54,17 @@ def ParseDepsFile(deps_file): return (local_scope.get('vars', {}), local_scope.get('deps', {})) +def GitHashArg(value): + """Validates that the string is a 40-character hex string.""" + # If the argument is not passed, argparse usually handles the 'None' + # default, but this check ensures the string matches the pattern. + if not re.match(r"^[0-9a-f]{40}$", value): + raise argparse.ArgumentTypeError( + f"'{value}' is not a valid full git hash. " + "Expected a 40-character hexadecimal string." + ) + return value + def ParseArgs(args): args = args[1:] parser = argparse.ArgumentParser( @@ -65,6 +77,9 @@ def ParseArgs(args): type=str, help='Flutter DEPS file.', default=FLUTTER_DEPS) + parser.add_argument('--dart_revision', '-r', + type=GitHashArg, + help='Dart revision to update to.') return parser.parse_args(args) def PrettifySourcePathForDEPS(flutter_vars, dep_path, source): @@ -191,8 +206,13 @@ def Main(argv): lines = file.readlines() i = 0 while i < len(lines): - updatedfile.write(lines[i]) if lines[i].startswith(" 'dart_revision':"): + if args.dart_revision is None: + # No dart revision supplied. Leave as-is. + updatedfile.write(lines[i]) + else: + updatedfile.write(" 'dart_revision': '%s',\n" % args.dart_revision) + i = i + 2 updatedfile.writelines([ '\n', @@ -205,6 +225,7 @@ def Main(argv): updatedfile.write('\n') elif lines[i].startswith(" # WARNING: Unused Dart dependencies"): + updatedfile.write(lines[i]) updatedfile.write('\n') i = i + 1 while i < len(lines) and not lines[i].startswith(" # WARNING: end of dart dependencies"): @@ -214,6 +235,9 @@ def Main(argv): updatedfile.write(f" '{dep_path}':\n {dep_source},\n\n") updatedfile.write(lines[i]) + + else: + updatedfile.write(lines[i]) i = i + 1 # Rename updated DEPS file into a new DEPS file diff --git a/packages/flutter/lib/src/cupertino/page_scaffold.dart b/packages/flutter/lib/src/cupertino/page_scaffold.dart index 2bb6b4711f257..e96233cb64bc4 100644 --- a/packages/flutter/lib/src/cupertino/page_scaffold.dart +++ b/packages/flutter/lib/src/cupertino/page_scaffold.dart @@ -8,7 +8,7 @@ /// @docImport 'tab_scaffold.dart'; library; -import 'package:flutter/foundation.dart'; +import 'package:flutter/rendering.dart'; import 'package:flutter/widgets.dart'; import 'colors.dart'; @@ -89,11 +89,43 @@ class CupertinoPageScaffold extends StatefulWidget { State createState() => _CupertinoPageScaffoldState(); } -class _CupertinoPageScaffoldState extends State { - void _handleStatusBarTap() { +class _CupertinoPageScaffoldState extends State with WidgetsBindingObserver { + final GlobalKey _statusBarKey = GlobalKey(); + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + } + + @override + void deactivate() { + WidgetsBinding.instance.removeObserver(this); + super.deactivate(); + } + + @override + void activate() { + super.activate(); + WidgetsBinding.instance.addObserver(this); + } + + @override + void handleStatusBarTap() { + super.handleStatusBarTap(); final ScrollController? primaryScrollController = PrimaryScrollController.maybeOf(context); - // Only act on the scroll controller if it has any attached scroll positions. - if (primaryScrollController != null && primaryScrollController.hasClients) { + if (primaryScrollController != null && + primaryScrollController.hasClients && + // TODO(LongCatIsLooong): the iOS embedder used to send status bar tap + // evets as fake touches at Offset.zero, such that at most one Scaffold + // (usually the foreground CupertinoPageScaffold) can handle the status + // bar tap event, thanks to hit-testing and gesture disambiguation. + // To keep that behavior, this widget performs an additional hit-test here + // to make sure the status bar tap is only handled if this scaffold is + // hit-testable (thus in the foreground). + // Switch to a better solution when available: + // https://github.com/flutter/flutter/issues/182403 + _HitTestableAtOrigin.hitTestableAtOrigin(_statusBarKey)) { primaryScrollController.animateTo( 0.0, // Eyeballed from iOS. @@ -194,7 +226,7 @@ class _CupertinoPageScaffoldState extends State { left: 0.0, right: 0.0, height: existingMediaQuery.padding.top, - child: GestureDetector(excludeFromSemantics: true, onTap: _handleStatusBarTap), + child: _HitTestableAtOrigin(_statusBarKey), ), ], ), @@ -250,3 +282,36 @@ abstract class ObstructingPreferredSizeWidget implements PreferredSizeWidget { /// If false, this widget partially obstructs. bool shouldFullyObstruct(BuildContext context); } + +final class _HitTestableAtOrigin extends StatelessWidget { + const _HitTestableAtOrigin(this.globalKey); + + final GlobalKey globalKey; + + /// Whether the render box of the [_HitTestableAtOrigin] widget associated + /// with the given global `key` is hit-testable at [Offset.zero]. + /// + /// This is used by the `handleStatusBarTap` implementation to avoid sending + /// status bar tap events to scroll views in offscreen subtrees. + static bool hitTestableAtOrigin(GlobalKey key) { + final context = key.currentContext as Element?; + if (context == null) { + assert(false, 'BuildContext associated with $key is not mounted.'); + return false; + } + final renderObject = context.renderObject! as RenderMetaData; + final int viewId = View.of(context).viewId; + final result = HitTestResult(); + WidgetsBinding.instance.hitTestInView(result, Offset.zero, viewId); + return result.path.any((HitTestEntry entry) => entry.target == renderObject); + } + + @override + Widget build(BuildContext context) { + return MetaData( + key: globalKey, + behavior: HitTestBehavior.translucent, + child: const SizedBox.expand(), + ); + } +} diff --git a/packages/flutter/lib/src/cupertino/sheet.dart b/packages/flutter/lib/src/cupertino/sheet.dart index e8acdfbdfc774..ff9dcd72a8a6f 100644 --- a/packages/flutter/lib/src/cupertino/sheet.dart +++ b/packages/flutter/lib/src/cupertino/sheet.dart @@ -230,7 +230,7 @@ class CupertinoSheetTransition extends StatefulWidget { required this.secondaryRouteAnimation, required this.child, required this.linearTransition, - required this.topGap, + this.topGap = _kTopGapRatio, }); /// `primaryRouteAnimation` is a linear route animation from 0.0 to 1.0 when @@ -252,9 +252,13 @@ class CupertinoSheetTransition extends StatefulWidget { /// The gap between the top of the screen and the top of the sheet as a ratio /// of the screen height. /// + ///{@template flutter.cupertino.CupertinoSheetTransition.topGap} /// This value should be between 0.0 and 0.9, where 0.0 means no gap (sheet /// extends to the top of the screen) and 0.9 means the sheet covers only the /// bottom 10% of the screen. A value of 0.08 represents 8% of the screen height. + /// + /// If not provided, defaults to a value of 0.08. + /// {@endtemplate} final double topGap; /// The primary delegated transition. Will slide a non [CupertinoSheetRoute] page down. @@ -736,7 +740,9 @@ mixin _CupertinoSheetRouteTransitionMixin on PageRoute { bool get enableDrag; /// The gap between the top of the screen and the top of the sheet as a ratio - /// of the screen height (0.0 to 1.0). Defaults to a value of 0.08. + /// of the screen height. + /// + /// {@macro flutter.cupertino.CupertinoSheetTransition.topGap} double get topGap; /// Whether a custom top gap has been set. diff --git a/packages/flutter/lib/src/material/dropdown_menu.dart b/packages/flutter/lib/src/material/dropdown_menu.dart index 2b89f75dde11b..387b4e0979786 100644 --- a/packages/flutter/lib/src/material/dropdown_menu.dart +++ b/packages/flutter/lib/src/material/dropdown_menu.dart @@ -173,7 +173,7 @@ enum DropdownMenuCloseBehavior { /// The [DropdownMenu] uses a [TextField] as the "anchor". /// * [TextField], which is a text input widget that uses an [InputDecoration]. /// * [DropdownMenuEntry], which is used to build the [MenuItemButton] in the [DropdownMenu] list. -class DropdownMenu extends StatefulWidget { +class DropdownMenu extends StatefulWidget { /// Creates a const [DropdownMenu]. /// /// The leading and trailing icons in the text field can be customized by using @@ -690,7 +690,7 @@ class DropdownMenu extends StatefulWidget { State> createState() => _DropdownMenuState(); } -class _DropdownMenuState extends State> { +class _DropdownMenuState extends State> { static const Map _editableShortcuts = { SingleActivator(LogicalKeyboardKey.arrowLeft): ExtendSelectionByCharacterIntent( forward: false, diff --git a/packages/flutter/lib/src/material/dropdown_menu_form_field.dart b/packages/flutter/lib/src/material/dropdown_menu_form_field.dart index 752e686da8768..cf47b8e029093 100644 --- a/packages/flutter/lib/src/material/dropdown_menu_form_field.dart +++ b/packages/flutter/lib/src/material/dropdown_menu_form_field.dart @@ -28,7 +28,7 @@ import 'menu_style.dart'; /// /// * [DropdownMenu], which is the underlying text field without the [Form] /// integration. -class DropdownMenuFormField extends FormField { +class DropdownMenuFormField extends FormField { /// Creates a [DropdownMenu] widget that is a [FormField]. /// /// For a description of the `onSaved`, `validator`, or `autovalidateMode` @@ -164,7 +164,7 @@ class DropdownMenuFormField extends FormField { FormFieldState createState() => _DropdownMenuFormFieldState(); } -class _DropdownMenuFormFieldState extends FormFieldState { +class _DropdownMenuFormFieldState extends FormFieldState { DropdownMenuFormField get _dropdownMenuFormField => widget as DropdownMenuFormField; // The controller used to restore the selected item. diff --git a/packages/flutter/lib/src/material/expansion_tile.dart b/packages/flutter/lib/src/material/expansion_tile.dart index a316d13a9303e..41bae613676f2 100644 --- a/packages/flutter/lib/src/material/expansion_tile.dart +++ b/packages/flutter/lib/src/material/expansion_tile.dart @@ -636,7 +636,7 @@ class _ExpansionTileState extends State { // blockNode prevents this node from being part of the focus traversal. label: semanticsHint, liveRegion: true, - accessiblityFocusBlockType: AccessiblityFocusBlockType.blockNode, + accessibilityFocusBlockType: AccessibilityFocusBlockType.blockNode, child: Semantics(hint: semanticsHint, onTapHint: onTapHint, child: child), ); } diff --git a/packages/flutter/lib/src/material/scaffold.dart b/packages/flutter/lib/src/material/scaffold.dart index c157cc5567afa..d121d10f1d660 100644 --- a/packages/flutter/lib/src/material/scaffold.dart +++ b/packages/flutter/lib/src/material/scaffold.dart @@ -21,7 +21,8 @@ import 'dart:math' as math; import 'dart:ui'; import 'package:flutter/foundation.dart'; -import 'package:flutter/gestures.dart' show DragStartBehavior; +import 'package:flutter/gestures.dart' show DragStartBehavior, HitTestEntry, HitTestResult; +import 'package:flutter/rendering.dart' show RenderMetaData; import 'package:flutter/widgets.dart'; import 'app_bar.dart'; @@ -2193,7 +2194,8 @@ class Scaffold extends StatefulWidget { /// /// Can display [BottomSheet]s. Retrieve a [ScaffoldState] from the current /// [BuildContext] using [Scaffold.of]. -class ScaffoldState extends State with TickerProviderStateMixin, RestorationMixin { +class ScaffoldState extends State + with TickerProviderStateMixin, RestorationMixin, WidgetsBindingObserver { @override String? get restorationId => widget.restorationId; @@ -2210,6 +2212,7 @@ class ScaffoldState extends State with TickerProviderStateMixin, Resto final GlobalKey _endDrawerKey = GlobalKey(); final GlobalKey _bodyKey = GlobalKey(); + late final GlobalKey _statusBarKey = GlobalKey(); /// Whether this scaffold has a non-null [Scaffold.appBar]. bool get hasAppBar => widget.appBar != null; @@ -2743,12 +2746,26 @@ class ScaffoldState extends State with TickerProviderStateMixin, Resto // iOS FEATURES - status bar tap, back gesture - // On iOS and macOS, if `primary` is true, tapping the status bar scrolls the app's primary scrollable + // On iOS, if `primary` is true, tapping the status bar scrolls the app's primary scrollable // to the top. We implement this by looking up the primary scroll controller and // scrolling it to the top when tapped. - void _handleStatusBarTap() { + @override + void handleStatusBarTap() { + super.handleStatusBarTap(); + assert(widget.primary); final ScrollController? primaryScrollController = PrimaryScrollController.maybeOf(context); - if (primaryScrollController != null && primaryScrollController.hasClients) { + if (primaryScrollController != null && + primaryScrollController.hasClients && + // TODO(LongCatIsLooong): the iOS embedder used to send status bar tap + // evets as fake touches at Offset.zero, such that at most one Scaffold + // (usually the foreground primary Scaffold) can handle the status bar + // tap event, thanks to hit-testing and gesture disambiguation. + // To keep that behavior, this widget performs an additional hit-test here + // to make sure the status bar tap is only handled if the scaffold is + // hit-testable (thus in the foreground) + // Switch to a better solution when available: + // https://github.com/flutter/flutter/issues/182403 + _HitTestableAtOrigin.hitTestableAtOrigin(_statusBarKey)) { primaryScrollController.animateTo( 0.0, duration: const Duration(milliseconds: 1000), @@ -2787,6 +2804,9 @@ class ScaffoldState extends State with TickerProviderStateMixin, Resto ); _bottomSheetScrimAnimationController = AnimationController(vsync: this); + if (widget.primary) { + WidgetsBinding.instance.addObserver(this); + } } @protected @@ -2828,6 +2848,13 @@ class ScaffoldState extends State with TickerProviderStateMixin, Resto _updatePersistentBottomSheet(); } } + switch ((oldWidget.primary, widget.primary)) { + case (true, false): + WidgetsBinding.instance.removeObserver(this); + case (false, true): + WidgetsBinding.instance.addObserver(this); + case (true, true) || (false, false): + } } @protected @@ -2849,6 +2876,20 @@ class ScaffoldState extends State with TickerProviderStateMixin, Resto super.didChangeDependencies(); } + @override + void deactivate() { + WidgetsBinding.instance.removeObserver(this); + super.deactivate(); + } + + @override + void activate() { + super.activate(); + if (widget.primary) { + WidgetsBinding.instance.addObserver(this); + } + } + @protected @override void dispose() { @@ -3150,32 +3191,24 @@ class ScaffoldState extends State with TickerProviderStateMixin, Resto removeBottomPadding: true, ); - switch (themeData.platform) { - case TargetPlatform.iOS: - case TargetPlatform.macOS: - if (!widget.primary) { - break; - } - _addIfNonNull( - children, - GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: _handleStatusBarTap, - // iOS accessibility automatically adds scroll-to-top to the clock in the status bar - excludeFromSemantics: true, - ), - _ScaffoldSlot.statusBar, - removeLeftPadding: false, - removeTopPadding: true, - removeRightPadding: false, - removeBottomPadding: true, - ); - case TargetPlatform.android: - case TargetPlatform.fuchsia: - case TargetPlatform.linux: - case TargetPlatform.windows: - break; - } + final Widget? statusBar = switch (themeData.platform) { + TargetPlatform.iOS || + TargetPlatform.macOS => widget.primary ? _HitTestableAtOrigin(_statusBarKey) : null, + TargetPlatform.android || + TargetPlatform.fuchsia || + TargetPlatform.linux || + TargetPlatform.windows => null, + }; + + _addIfNonNull( + children, + statusBar, + _ScaffoldSlot.statusBar, + removeLeftPadding: false, + removeTopPadding: true, + removeRightPadding: false, + removeBottomPadding: true, + ); if (_endDrawerOpened.value) { _buildDrawer(children, textDirection); @@ -3452,3 +3485,41 @@ class _ScaffoldScope extends InheritedWidget { return hasDrawer != oldWidget.hasDrawer; } } + +final class _HitTestableAtOrigin extends StatelessWidget { + const _HitTestableAtOrigin(this.globalKey); + + final GlobalKey globalKey; + + /// Whether the render box of the [_HitTestableAtOrigin] widget associated + /// with the given global `key` is hit-testable at [Offset.zero]. + /// + /// This is used by the `handleStatusBarTap` implementation to avoid sending + /// status bar tap events to scroll views in offscreen subtrees. + static bool hitTestableAtOrigin(GlobalKey key) { + final context = key.currentContext as Element?; + if (context == null) { + assert( + false, + 'BuildContext associated with $key is not mounted. ' + 'If you see this in a test, this is likely because the test was trying ' + 'to simulate status bar tap on a non-iOS platform', + ); + return false; + } + final renderObject = context.renderObject! as RenderMetaData; + final int viewId = View.of(context).viewId; + final result = HitTestResult(); + WidgetsBinding.instance.hitTestInView(result, Offset.zero, viewId); + return result.path.any((HitTestEntry entry) => entry.target == renderObject); + } + + @override + Widget build(BuildContext context) { + return MetaData( + key: globalKey, + behavior: HitTestBehavior.translucent, + child: const SizedBox.expand(), + ); + } +} diff --git a/packages/flutter/lib/src/rendering/custom_paint.dart b/packages/flutter/lib/src/rendering/custom_paint.dart index b24808224938a..b280cfe5bacab 100644 --- a/packages/flutter/lib/src/rendering/custom_paint.dart +++ b/packages/flutter/lib/src/rendering/custom_paint.dart @@ -950,8 +950,8 @@ class RenderCustomPaint extends RenderProxyBox { if (properties.focused != null) { config.isFocused = properties.focused; } - if (properties.accessiblityFocusBlockType != null) { - config.accessiblityFocusBlockType = properties.accessiblityFocusBlockType!; + if (properties.accessibilityFocusBlockType != null) { + config.accessibilityFocusBlockType = properties.accessibilityFocusBlockType!; } if (properties.enabled != null) { config.isEnabled = properties.enabled; diff --git a/packages/flutter/lib/src/rendering/object.dart b/packages/flutter/lib/src/rendering/object.dart index 2a01de7f33073..0d849a4e4877d 100644 --- a/packages/flutter/lib/src/rendering/object.dart +++ b/packages/flutter/lib/src/rendering/object.dart @@ -4853,8 +4853,8 @@ mixin SemanticsAnnotationsMixin on RenderObject { if (_properties.focused != null) { config.isFocused = _properties.focused; } - if (_properties.accessiblityFocusBlockType != null) { - config.accessiblityFocusBlockType = _properties.accessiblityFocusBlockType!; + if (_properties.accessibilityFocusBlockType != null) { + config.accessibilityFocusBlockType = _properties.accessibilityFocusBlockType!; } if (_properties.inMutuallyExclusiveGroup != null) { config.isInMutuallyExclusiveGroup = _properties.inMutuallyExclusiveGroup!; @@ -5131,7 +5131,7 @@ final class _SemanticsParentData { required this.explicitChildNodes, required this.tagsForChildren, required this.localeForChildren, - required this.accessiblityFocusBlockType, + required this.accessibilityFocusBlockType, }); /// Whether [SemanticsNode]s created from this render object semantics subtree @@ -5154,7 +5154,7 @@ final class _SemanticsParentData { /// * **blockNode**: Blocks accessibility focus for the **current node only**. /// /// Only `blockSubtree` from a parent will be propagated down. - final AccessiblityFocusBlockType? accessiblityFocusBlockType; + final AccessibilityFocusBlockType? accessibilityFocusBlockType; /// Any immediate render object semantics that /// [_RenderObjectSemantics.contributesToSemanticsTree] should forms a node @@ -5581,11 +5581,11 @@ class _RenderObjectSemantics extends _SemanticsFragment with DiagnosticableTreeM final bool blocksUserAction = (parentData?.blocksUserActions ?? false) || configProvider.effective.isBlockingUserActions; - AccessiblityFocusBlockType accessiblityFocusBlockType; - if (parentData?.accessiblityFocusBlockType == AccessiblityFocusBlockType.blockSubtree) { - accessiblityFocusBlockType = AccessiblityFocusBlockType.blockSubtree; + AccessibilityFocusBlockType accessibilityFocusBlockType; + if (parentData?.accessibilityFocusBlockType == AccessibilityFocusBlockType.blockSubtree) { + accessibilityFocusBlockType = AccessibilityFocusBlockType.blockSubtree; } else { - accessiblityFocusBlockType = configProvider.effective.accessiblityFocusBlockType; + accessibilityFocusBlockType = configProvider.effective.accessibilityFocusBlockType; } // localeForSubtree from the config overrides parentData's inherited locale. @@ -5599,7 +5599,7 @@ class _RenderObjectSemantics extends _SemanticsFragment with DiagnosticableTreeM (parentData?.mergeIntoParent ?? false) || configProvider.effective.isMergingSemanticsOfDescendants, blocksUserActions: blocksUserAction, - accessiblityFocusBlockType: accessiblityFocusBlockType, + accessibilityFocusBlockType: accessibilityFocusBlockType, localeForChildren: localeForChildren, explicitChildNodes: explicitChildNodesForChildren, tagsForChildren: tagsForChildren, @@ -5645,9 +5645,9 @@ class _RenderObjectSemantics extends _SemanticsFragment with DiagnosticableTreeM tags.forEach(config.addTagForChildren); }); } - if (accessiblityFocusBlockType != configProvider.effective.accessiblityFocusBlockType) { + if (accessibilityFocusBlockType != configProvider.effective.accessibilityFocusBlockType) { configProvider.updateConfig((SemanticsConfiguration config) { - config.accessiblityFocusBlockType = accessiblityFocusBlockType; + config.accessibilityFocusBlockType = accessibilityFocusBlockType; }); } @@ -5725,7 +5725,7 @@ class _RenderObjectSemantics extends _SemanticsFragment with DiagnosticableTreeM effectiveChildParentData = _SemanticsParentData( mergeIntoParent: childParentData.mergeIntoParent, blocksUserActions: childParentData.blocksUserActions, - accessiblityFocusBlockType: childParentData.accessiblityFocusBlockType, + accessibilityFocusBlockType: childParentData.accessibilityFocusBlockType, explicitChildNodes: false, tagsForChildren: childParentData.tagsForChildren, localeForChildren: childParentData.localeForChildren, diff --git a/packages/flutter/lib/src/semantics/semantics.dart b/packages/flutter/lib/src/semantics/semantics.dart index f6eac48cabd18..ab7896138ceda 100644 --- a/packages/flutter/lib/src/semantics/semantics.dart +++ b/packages/flutter/lib/src/semantics/semantics.dart @@ -119,7 +119,7 @@ typedef ChildSemanticsConfigurationsDelegate = /// /// This is typically used to prevent screen readers /// from focusing on parts of the UI. -enum AccessiblityFocusBlockType { +enum AccessibilityFocusBlockType { /// Accessibility focus is **not blocked**. none, @@ -130,22 +130,22 @@ enum AccessiblityFocusBlockType { /// may still be focusable. blockNode; - /// The AccessiblityFocusBlockType when two nodes get merged. - AccessiblityFocusBlockType _merge(AccessiblityFocusBlockType other) { + /// The AccessibilityFocusBlockType when two nodes get merged. + AccessibilityFocusBlockType _merge(AccessibilityFocusBlockType other) { // 1. If either is blockSubtree, the result is blockSubtree. - if (this == AccessiblityFocusBlockType.blockSubtree || - other == AccessiblityFocusBlockType.blockSubtree) { - return AccessiblityFocusBlockType.blockSubtree; + if (this == AccessibilityFocusBlockType.blockSubtree || + other == AccessibilityFocusBlockType.blockSubtree) { + return AccessibilityFocusBlockType.blockSubtree; } // 2. If either is blockNode, the result is blockNode - if (this == AccessiblityFocusBlockType.blockNode || - other == AccessiblityFocusBlockType.blockNode) { - return AccessiblityFocusBlockType.blockNode; + if (this == AccessibilityFocusBlockType.blockNode || + other == AccessibilityFocusBlockType.blockNode) { + return AccessibilityFocusBlockType.blockNode; } // 3. If neither is blockSubtree nor blockNode, both must be none. - return AccessiblityFocusBlockType.none; + return AccessibilityFocusBlockType.none; } } @@ -1642,7 +1642,7 @@ class SemanticsProperties extends DiagnosticableTree { ) this.focusable, this.focused, - this.accessiblityFocusBlockType, + this.accessibilityFocusBlockType, this.inMutuallyExclusiveGroup, this.hidden, this.obscured, @@ -1857,7 +1857,7 @@ class SemanticsProperties extends DiagnosticableTree { /// This is for accessibility focus, which is the focus used by screen readers /// like TalkBack and VoiceOver. It is different from input focus, which is /// usually held by the element that currently responds to keyboard inputs. - final AccessiblityFocusBlockType? accessiblityFocusBlockType; + final AccessibilityFocusBlockType? accessibilityFocusBlockType; /// If non-null, whether a semantic node is in a mutually exclusive group. /// @@ -6306,14 +6306,16 @@ class SemanticsConfiguration { _hasBeenAnnotated = true; } - AccessiblityFocusBlockType _accessiblityFocusBlockType = AccessiblityFocusBlockType.none; + AccessibilityFocusBlockType _accessibilityFocusBlockType = AccessibilityFocusBlockType.none; /// Whether the owning [RenderObject] and its subtree /// is blocked in the a11y focus (different from input focus). - AccessiblityFocusBlockType get accessiblityFocusBlockType => _accessiblityFocusBlockType; - set accessiblityFocusBlockType(AccessiblityFocusBlockType value) { - _accessiblityFocusBlockType = value; - _flags = _flags.copyWith(isAccessibilityFocusBlocked: value != AccessiblityFocusBlockType.none); + AccessibilityFocusBlockType get accessibilityFocusBlockType => _accessibilityFocusBlockType; + set accessibilityFocusBlockType(AccessibilityFocusBlockType value) { + _accessibilityFocusBlockType = value; + _flags = _flags.copyWith( + isAccessibilityFocusBlocked: value != AccessibilityFocusBlockType.none, + ); _hasBeenAnnotated = true; } @@ -6797,8 +6799,8 @@ class SemanticsConfiguration { _validationResult = child._validationResult; } } - _accessiblityFocusBlockType = _accessiblityFocusBlockType._merge( - child._accessiblityFocusBlockType, + _accessibilityFocusBlockType = _accessibilityFocusBlockType._merge( + child._accessibilityFocusBlockType, ); _minValue ??= child._minValue; _maxValue ??= child._maxValue; @@ -6829,7 +6831,7 @@ class SemanticsConfiguration { .._attributedValue = _attributedValue .._attributedDecreasedValue = _attributedDecreasedValue .._attributedHint = _attributedHint - .._accessiblityFocusBlockType = _accessiblityFocusBlockType + .._accessibilityFocusBlockType = _accessibilityFocusBlockType .._hintOverrides = _hintOverrides .._tooltip = _tooltip .._flags = _flags diff --git a/packages/flutter/lib/src/services/binding.dart b/packages/flutter/lib/src/services/binding.dart index b17de4614fc4c..2b397b8870813 100644 --- a/packages/flutter/lib/src/services/binding.dart +++ b/packages/flutter/lib/src/services/binding.dart @@ -436,9 +436,7 @@ mixin ServicesBinding on BindingBase, SchedulerBinding { _systemContextMenuClient!.handleCustomContextMenuAction(callbackId); case 'SystemChrome.systemUIChange': final args = methodCall.arguments as List; - if (_systemUiChangeCallback != null) { - await _systemUiChangeCallback!(args[0] as bool); - } + await _systemUiChangeCallback?.call(args[0] as bool); case 'System.requestAppExit': return {'response': (await handleRequestAppExit()).name}; default: diff --git a/packages/flutter/lib/src/services/system_channels.dart b/packages/flutter/lib/src/services/system_channels.dart index 4f7e94ced8f56..83c70be7e1dc2 100644 --- a/packages/flutter/lib/src/services/system_channels.dart +++ b/packages/flutter/lib/src/services/system_channels.dart @@ -183,6 +183,26 @@ abstract final class SystemChannels { JSONMethodCodec(), ); + /// An unidirectional JSON [MethodChannel] for receiving status bar related + /// events from iOS. + /// + /// The only method this channel receives is `handleScrollToTop` which + /// is called on iOS when the user taps the status bar to scroll a scroll view + /// to the top. + /// + /// Typically you should not subscribe to this channel directly. The events are + /// dispatched to registered [WidgetsBindingObserver]s via the + /// [WidgetsBindingObserver.handleStatusBarTap] callback. + /// + /// See also: + /// + /// * [WidgetsBindingObserver.handleStatusBarTap], the widgets library callback + /// for dispatching the status bar tap event to the widget tree. + static const OptionalMethodChannel statusBar = OptionalMethodChannel( + 'flutter/status_bar', + JSONMethodCodec(), + ); + /// A [MethodChannel] for handling text processing actions. /// /// This channel exposes the text processing feature for supported platforms. diff --git a/packages/flutter/lib/src/widgets/basic.dart b/packages/flutter/lib/src/widgets/basic.dart index 66c7914b3a061..197bb365e145b 100644 --- a/packages/flutter/lib/src/widgets/basic.dart +++ b/packages/flutter/lib/src/widgets/basic.dart @@ -4048,7 +4048,7 @@ sealed class _SemanticsBase extends SingleChildRenderObjectWidget { required bool? readOnly, required bool? focusable, required bool? focused, - required AccessiblityFocusBlockType? accessiblityFocusBlockType, + required AccessibilityFocusBlockType? accessibilityFocusBlockType, required bool? inMutuallyExclusiveGroup, required bool? obscured, required bool? multiline, @@ -4136,7 +4136,7 @@ sealed class _SemanticsBase extends SingleChildRenderObjectWidget { readOnly: readOnly, focusable: focusable, focused: focused, - accessiblityFocusBlockType: accessiblityFocusBlockType, + accessibilityFocusBlockType: accessibilityFocusBlockType, inMutuallyExclusiveGroup: inMutuallyExclusiveGroup, obscured: obscured, multiline: multiline, @@ -4384,7 +4384,7 @@ class SliverSemantics extends _SemanticsBase { super.readOnly, super.focusable, super.focused, - super.accessiblityFocusBlockType, + super.accessibilityFocusBlockType, super.inMutuallyExclusiveGroup, super.obscured, super.multiline, @@ -7969,7 +7969,7 @@ class Semantics extends _SemanticsBase { super.readOnly, super.focusable, super.focused, - super.accessiblityFocusBlockType, + super.accessibilityFocusBlockType, super.inMutuallyExclusiveGroup, super.obscured, super.multiline, diff --git a/packages/flutter/lib/src/widgets/binding.dart b/packages/flutter/lib/src/widgets/binding.dart index fb1e29060d313..670fa972aac8b 100644 --- a/packages/flutter/lib/src/widgets/binding.dart +++ b/packages/flutter/lib/src/widgets/binding.dart @@ -152,6 +152,22 @@ abstract mixin class WidgetsBindingObserver { /// predictive back feature. void handleCancelBackGesture() {} + /// Called when the user taps the status bar on iOS, to scroll a scroll + /// view to the top. + /// + /// This event should usually only be handled by at most one scroll view, so + /// implementer(s) of this callback must coordinate to determine the most + /// suitable scroll view for handling this event. + /// + /// This callback is only called on iOS. The default implementation provided by + /// [WidgetsBindingObserver] does nothing. + /// + /// See also: + /// + /// * [Scaffold] and [CupertinoPageScaffold] which use this callback to implement + /// iOS scroll-to-top. + void handleStatusBarTap() {} + /// Called when the host tells the application to push a new route onto the /// navigator. /// @@ -461,6 +477,7 @@ mixin WidgetsBinding platformDispatcher.onLocaleChanged = handleLocaleChanged; SystemChannels.navigation.setMethodCallHandler(_handleNavigationInvocation); SystemChannels.backGesture.setMethodCallHandler(_handleBackGestureInvocation); + SystemChannels.statusBar.setMethodCallHandler(_handleStatusBarActions); assert(() { FlutterErrorDetails.propertiesTransformers.add(debugTransformDebugCreator); return true; @@ -908,6 +925,24 @@ mixin WidgetsBinding } } + Future _handleStatusBarActions(MethodCall call) async { + assert(call.method == 'handleScrollToTop'); + for (final observer in List.of(_observers)) { + try { + observer.handleStatusBarTap(); + } catch (exception, stack) { + final details = FlutterErrorDetails( + exception: exception, + stack: stack, + library: 'widgets library', + context: ErrorDescription('handling status bar action'), + ); + FlutterError.reportError(details); + // No error widget possible here since it wouldn't have a view to render into. + } + } + } + /// Called when the system pops the current route. /// /// This first notifies the binding observers (using diff --git a/packages/flutter/test/cupertino/scaffold_test.dart b/packages/flutter/test/cupertino/scaffold_test.dart index 78eb57da9a0ac..a9096e6df5ba5 100644 --- a/packages/flutter/test/cupertino/scaffold_test.dart +++ b/packages/flutter/test/cupertino/scaffold_test.dart @@ -424,64 +424,6 @@ void main() { expect(decoration.color, const Color(0xFF010203)); }); - testWidgets('Lists in CupertinoPageScaffold scroll to the top when status bar tapped', ( - WidgetTester tester, - ) async { - await tester.pumpWidget( - CupertinoApp( - builder: (BuildContext context, Widget? child) { - // Acts as a 20px status bar at the root of the app. - return MediaQuery( - data: MediaQuery.of(context).copyWith(padding: const EdgeInsets.only(top: 20)), - child: child!, - ); - }, - home: CupertinoPageScaffold( - // Default nav bar is translucent. - navigationBar: const CupertinoNavigationBar(middle: Text('Title')), - child: ListView.builder( - itemExtent: 50, - itemBuilder: (BuildContext context, int index) => Text(index.toString()), - ), - ), - ), - ); - // Top media query padding 20 + translucent nav bar 44. - expect(tester.getTopLeft(find.text('0')).dy, 64); - expect(tester.getTopLeft(find.text('6')).dy, 364); - - await tester.fling( - find.text('5'), // Find some random text on the screen. - const Offset(0, -200), - 20, - ); - - await tester.pumpAndSettle(); - - expect(tester.getTopLeft(find.text('6')).dy, moreOrLessEquals(166.833, epsilon: 0.1)); - expect( - tester.getTopLeft(find.text('12')).dy, - moreOrLessEquals(466.8333333333334, epsilon: 0.1), - ); - - // The media query top padding is 20. Tapping at 20 should do nothing. - await tester.tapAt(const Offset(400, 20)); - await tester.pumpAndSettle(); - expect(tester.getTopLeft(find.text('6')).dy, moreOrLessEquals(166.833, epsilon: 0.1)); - expect( - tester.getTopLeft(find.text('12')).dy, - moreOrLessEquals(466.8333333333334, epsilon: 0.1), - ); - - // Tap 1 pixel higher. - await tester.tapAt(const Offset(400, 19)); - await tester.pump(); - await tester.pump(const Duration(milliseconds: 500)); - expect(tester.getTopLeft(find.text('0')).dy, 64); - expect(tester.getTopLeft(find.text('6')).dy, 364); - expect(find.text('12'), findsNothing); - }); - testWidgets('resizeToAvoidBottomInset is supported even when no navigationBar', ( WidgetTester tester, ) async { @@ -588,6 +530,67 @@ void main() { ); }); + testWidgets('Tap the status bar scrolls to top', (WidgetTester tester) async { + final scrollController = ScrollController(initialScrollOffset: 1000); + addTearDown(scrollController.dispose); + await tester.pumpWidget( + CupertinoApp( + home: MediaQuery( + data: const MediaQueryData(padding: EdgeInsets.only(top: 25.0)), // status bar + child: Builder( + builder: (BuildContext context) { + return PrimaryScrollController( + controller: scrollController, + child: const CupertinoPageScaffold( + child: SingleChildScrollView(primary: true, child: SizedBox(height: 12345)), + ), + ); + }, + ), + ), + ), + ); + + tester.simulateStatusBarTap(); + await tester.pumpAndSettle(); + + expect(scrollController.offset, 0.0); + }); + + testWidgets('status bar tap only scrolls the foregrounded primary controller', ( + WidgetTester tester, + ) async { + final app = CupertinoApp( + initialRoute: 'a', + onGenerateInitialRoutes: (initialRoute) { + return [ + CupertinoPageRoute(builder: (context) => _ScaffoldWithPrimaryScrollView()), + CupertinoPageRoute(builder: (context) => _ScaffoldWithPrimaryScrollView()), + ]; + }, + onGenerateRoute: (_) => throw UnimplementedError(), + ); + await tester.pumpWidget(app); + + final Iterable scrollables = tester.stateList( + find.descendant( + of: find.byType(_ScaffoldWithPrimaryScrollView, skipOffstage: false), + matching: find.byType(Scrollable, skipOffstage: false), + skipOffstage: false, + ), + ); + + final [ScrollableState scrollable1, ScrollableState scrollable2] = scrollables.toList(); + expect(scrollable1.position.pixels, 1000); + expect(scrollable2.position.pixels, 1000); + + tester.simulateStatusBarTap(); + await tester.pumpAndSettle(); + + expect(scrollable1.position.pixels, 1000); + expect(scrollable2.position.pixels, 0); + }); + testWidgets('CupertinoPageScaffold does not crash at zero area', (WidgetTester tester) async { await tester.pumpWidget( const CupertinoApp( @@ -599,3 +602,24 @@ void main() { expect(tester.getSize(find.byType(CupertinoPageScaffold)), Size.zero); }); } + +class _ScaffoldWithPrimaryScrollView extends StatefulWidget { + @override + State createState() => _ScaffoldWithPrimaryScrollViewState(); +} + +class _ScaffoldWithPrimaryScrollViewState extends State<_ScaffoldWithPrimaryScrollView> { + final ScrollController controller = ScrollController(initialScrollOffset: 1000); + @override + Widget build(BuildContext context) { + return MediaQuery( + data: const MediaQueryData(padding: EdgeInsets.only(top: 25.0)), // status bar + child: PrimaryScrollController( + controller: controller, + child: const CupertinoPageScaffold( + child: SingleChildScrollView(primary: true, child: SizedBox(height: 2000)), + ), + ), + ); + } +} diff --git a/packages/flutter/test/cupertino/sheet_test.dart b/packages/flutter/test/cupertino/sheet_test.dart index bb131ae5d83c7..c0c8dce6c2be8 100644 --- a/packages/flutter/test/cupertino/sheet_test.dart +++ b/packages/flutter/test/cupertino/sheet_test.dart @@ -2018,7 +2018,6 @@ void main() { CupertinoSheetTransition( primaryRouteAnimation: animation, secondaryRouteAnimation: secondaryAnimation, - topGap: 0.08, linearTransition: false, child: const SizedBox(height: 100, width: 100), ), @@ -2031,7 +2030,6 @@ void main() { CupertinoSheetTransition( primaryRouteAnimation: newAnimation, secondaryRouteAnimation: secondaryAnimation, - topGap: 0.08, linearTransition: false, child: const SizedBox(height: 100, width: 100), ), diff --git a/packages/flutter/test/material/dropdown_menu_form_field_test.dart b/packages/flutter/test/material/dropdown_menu_form_field_test.dart index 984fbd675d95e..ef0f5fc5d7236 100644 --- a/packages/flutter/test/material/dropdown_menu_form_field_test.dart +++ b/packages/flutter/test/material/dropdown_menu_form_field_test.dart @@ -32,6 +32,14 @@ void main() { return find.widgetWithText(MenuItemButton, menuItem.label).last; } + Finder findMenuItemButton(String label) { + // For each menu items there are two MenuItemButton widgets. + // The last one is the real button item in the menu. + // The first one is not visible, it is part of _DropdownMenuBody + // which is used to compute the dropdown width. + return find.widgetWithText(MenuItemButton, label).last; + } + testWidgets('Creates an underlying DropdownMenu', (WidgetTester tester) async { await tester.pumpWidget( MaterialApp( @@ -1394,4 +1402,47 @@ void main() { ); expect(tester.getSize(find.byType(DropdownMenuFormField)), Size.zero); }); + + // Regression test for https://github.com/flutter/flutter/issues/180121. + testWidgets('Allow null entry to clear selection', (WidgetTester tester) async { + final controller = TextEditingController(); + addTearDown(controller.dispose); + + const selectNoneLabel = 'Select none'; + final nullableMenuItems = >[ + const DropdownMenuEntry(value: null, label: selectNoneLabel), + const DropdownMenuEntry(value: 'a', label: 'A'), + const DropdownMenuEntry(value: 'b', label: 'B'), + ]; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return DropdownMenuFormField( + controller: controller, + requestFocusOnTap: true, + enableFilter: true, + dropdownMenuEntries: nullableMenuItems, + onSelected: (_) { + setState(() {}); + }, + ); + }, + ), + ), + ), + ); + + // Open the menu. + await tester.tap(find.byType(DropdownMenu)); + await tester.pump(); + + // Select the 'None' item. + await tester.tap(findMenuItemButton(selectNoneLabel)); + await tester.pumpAndSettle(); + + expect(controller.text, selectNoneLabel); + }); } diff --git a/packages/flutter/test/material/dropdown_menu_test.dart b/packages/flutter/test/material/dropdown_menu_test.dart index 6283b2f0213d3..a30c618d8dc44 100644 --- a/packages/flutter/test/material/dropdown_menu_test.dart +++ b/packages/flutter/test/material/dropdown_menu_test.dart @@ -5333,6 +5333,49 @@ void main() { shouldFocusPrevious: textInputAction == TextInputAction.previous, ); }, variant: focusVariants); + + // Regression test for https://github.com/flutter/flutter/issues/180121. + testWidgets('Allow null entry to clear selection', (WidgetTester tester) async { + final controller = TextEditingController(); + addTearDown(controller.dispose); + + const selectNoneLabel = 'Select none'; + final nullableMenuItems = >[ + const DropdownMenuEntry(value: null, label: selectNoneLabel), + const DropdownMenuEntry(value: 'a', label: 'A'), + const DropdownMenuEntry(value: 'b', label: 'B'), + ]; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return DropdownMenu( + controller: controller, + requestFocusOnTap: true, + enableFilter: true, + dropdownMenuEntries: nullableMenuItems, + onSelected: (_) { + setState(() {}); + }, + ); + }, + ), + ), + ), + ); + + // Open the menu. + await tester.tap(find.byType(DropdownMenu)); + await tester.pump(); + + // Select the 'None' item. + await tester.tap(findMenuItemButton(selectNoneLabel)); + await tester.pumpAndSettle(); + + expect(controller.text, selectNoneLabel); + }); } enum TestMenu { diff --git a/packages/flutter/test/material/scaffold_test.dart b/packages/flutter/test/material/scaffold_test.dart index 738fbdcfceff3..91f1cb656f9ec 100644 --- a/packages/flutter/test/material/scaffold_test.dart +++ b/packages/flutter/test/material/scaffold_test.dart @@ -574,9 +574,8 @@ void main() { expect(renderBox.size.height, equals(appBarHeight)); }); - Widget buildStatusBarTestApp(TargetPlatform? platform) { + Widget buildStatusBarTestApp() { return MaterialApp( - theme: ThemeData(platform: platform), home: MediaQuery( data: const MediaQueryData(padding: EdgeInsets.only(top: 25.0)), // status bar child: Scaffold( @@ -597,23 +596,6 @@ void main() { ); } - testWidgets( - 'Tapping the status bar scrolls to top', - (WidgetTester tester) async { - await tester.pumpWidget(buildStatusBarTestApp(debugDefaultTargetPlatformOverride)); - final ScrollableState scrollable = tester.state(find.byType(Scrollable)); - scrollable.position.jumpTo(500.0); - expect(scrollable.position.pixels, equals(500.0)); - await tester.tapAt(const Offset(100.0, 10.0)); - await tester.pumpAndSettle(); - expect(scrollable.position.pixels, equals(0.0)); - }, - variant: const TargetPlatformVariant({ - TargetPlatform.iOS, - TargetPlatform.macOS, - }), - ); - testWidgets( 'No status bar when primary is false', (WidgetTester tester) async { @@ -699,11 +681,11 @@ void main() { final stops = [0.842, 0.959, 0.993, 1.0]; const double scrollOffset = 1000; - await tester.pumpWidget(buildStatusBarTestApp(debugDefaultTargetPlatformOverride)); + await tester.pumpWidget(buildStatusBarTestApp()); final ScrollableState scrollable = tester.state(find.byType(Scrollable)); scrollable.position.jumpTo(scrollOffset); - await tester.tapAt(const Offset(100.0, 10.0)); + tester.simulateStatusBarTap(); await tester.pump(Duration.zero); expect(scrollable.position.pixels, equals(scrollOffset)); @@ -711,31 +693,53 @@ void main() { await tester.pump(Duration(milliseconds: duration ~/ stops.length)); // Scroll pixel position is very long double, compare with floored int // pixel position - expect(scrollable.position.pixels.toInt(), equals((scrollOffset * (1 - stops[i])).toInt())); + expect( + scrollable.position.pixels.toInt(), + equals((scrollOffset * (1 - stops[i])).toInt()), + reason: 'stop $i', + ); } // Finally stops at the top. expect(scrollable.position.pixels, equals(0.0)); }, - variant: const TargetPlatformVariant({ - TargetPlatform.iOS, - TargetPlatform.macOS, - }), + variant: TargetPlatformVariant.only(TargetPlatform.iOS), ); testWidgets( - 'Tapping the status bar does not scroll to top', + 'status bar tap only scrolls the foregrounded primary controller', (WidgetTester tester) async { - await tester.pumpWidget(buildStatusBarTestApp(TargetPlatform.android)); - final ScrollableState scrollable = tester.state(find.byType(Scrollable)); - scrollable.position.jumpTo(500.0); - expect(scrollable.position.pixels, equals(500.0)); - await tester.tapAt(const Offset(100.0, 10.0)); - await tester.pump(); - await tester.pump(const Duration(seconds: 1)); - expect(scrollable.position.pixels, equals(500.0)); + final app = MaterialApp( + initialRoute: 'a', + onGenerateInitialRoutes: (initialRoute) { + return [ + MaterialPageRoute(builder: (context) => _ScaffoldWithPrimaryScrollView()), + MaterialPageRoute(builder: (context) => _ScaffoldWithPrimaryScrollView()), + ]; + }, + onGenerateRoute: (_) => throw UnimplementedError(), + ); + await tester.pumpWidget(app); + + final Iterable scrollables = tester.stateList( + find.descendant( + of: find.byType(_ScaffoldWithPrimaryScrollView, skipOffstage: false), + matching: find.byType(Scrollable, skipOffstage: false), + skipOffstage: false, + ), + ); + + final [ScrollableState scrollable1, ScrollableState scrollable2] = scrollables.toList(); + expect(scrollable1.position.pixels, 1000); + expect(scrollable2.position.pixels, 1000); + + tester.simulateStatusBarTap(); + await tester.pumpAndSettle(); + + expect(scrollable1.position.pixels, 1000); + expect(scrollable2.position.pixels, 0); }, - variant: const TargetPlatformVariant({TargetPlatform.android}), + variant: TargetPlatformVariant.only(TargetPlatform.iOS), ); testWidgets('Bottom sheet cannot overlap app bar', (WidgetTester tester) async { @@ -3786,3 +3790,24 @@ class _CustomPageRoute extends PageRoute { return child; } } + +class _ScaffoldWithPrimaryScrollView extends StatefulWidget { + @override + State createState() => _ScaffoldWithPrimaryScrollViewState(); +} + +class _ScaffoldWithPrimaryScrollViewState extends State<_ScaffoldWithPrimaryScrollView> { + final ScrollController controller = ScrollController(initialScrollOffset: 1000); + @override + Widget build(BuildContext context) { + return MediaQuery( + data: const MediaQueryData(padding: EdgeInsets.only(top: 25.0)), // status bar + child: PrimaryScrollController( + controller: controller, + child: const Scaffold( + body: SingleChildScrollView(primary: true, child: SizedBox(height: 2000)), + ), + ), + ); + } +} diff --git a/packages/flutter/test/services/system_chrome_test.dart b/packages/flutter/test/services/system_chrome_test.dart index 6df47aa7ef7a1..b3f7bac714384 100644 --- a/packages/flutter/test/services/system_chrome_test.dart +++ b/packages/flutter/test/services/system_chrome_test.dart @@ -22,6 +22,11 @@ void main() { }, ); + // Start with the overlay style set to dark mode. + SystemChrome.setSystemUIOverlayStyle(SystemUiOverlayStyle.dark); + await tester.idle(); + log.clear(); + // The first call is a cache miss and will queue a microtask SystemChrome.setSystemUIOverlayStyle(SystemUiOverlayStyle.light); expect(tester.binding.microtaskCount, equals(1)); diff --git a/packages/flutter/test/widgets/binding_test.dart b/packages/flutter/test/widgets/binding_test.dart index c19bc2b1b141d..1357aa499d99f 100644 --- a/packages/flutter/test/widgets/binding_test.dart +++ b/packages/flutter/test/widgets/binding_test.dart @@ -176,6 +176,12 @@ class RentrantObserver implements WidgetsBindingObserver { WidgetsBinding.instance.addObserver(this); return Future.value(AppExitResponse.exit); } + + @override + void handleStatusBarTap() { + assert(active); + WidgetsBinding.instance.addObserver(this); + } } void main() { diff --git a/packages/flutter/test/widgets/interactive_viewer_test.dart b/packages/flutter/test/widgets/interactive_viewer_test.dart index e4ef3492f0d6c..1dc3d8f8278c3 100644 --- a/packages/flutter/test/widgets/interactive_viewer_test.dart +++ b/packages/flutter/test/widgets/interactive_viewer_test.dart @@ -1044,30 +1044,27 @@ void main() { var calledStart = false; var calledUpdate = false; var calledEnd = false; + const sizedBox = SizedBox(width: 200.0, height: 200.0); await tester.pumpWidget( - MaterialApp( - home: Scaffold( - body: Center( - child: InteractiveViewer( - transformationController: transformationController, - scaleEnabled: false, - onInteractionStart: (ScaleStartDetails details) { - calledStart = true; - }, - onInteractionUpdate: (ScaleUpdateDetails details) { - calledUpdate = true; - }, - onInteractionEnd: (ScaleEndDetails details) { - calledEnd = true; - }, - child: const SizedBox(width: 200.0, height: 200.0), - ), - ), + Center( + child: InteractiveViewer( + transformationController: transformationController, + scaleEnabled: false, + onInteractionStart: (ScaleStartDetails details) { + calledStart = true; + }, + onInteractionUpdate: (ScaleUpdateDetails details) { + calledUpdate = true; + }, + onInteractionEnd: (ScaleEndDetails details) { + calledEnd = true; + }, + child: sizedBox, ), ), ); - final Offset childOffset = tester.getTopLeft(find.byType(SizedBox)); + final Offset childOffset = tester.getTopLeft(find.byWidget(sizedBox)); final childInterior = Offset(childOffset.dx + 20.0, childOffset.dy + 20.0); TestGesture gesture = await tester.startGesture(childOffset); @@ -1119,30 +1116,27 @@ void main() { var calledStart = false; var calledUpdate = false; var calledEnd = false; + const sizedBox = SizedBox(width: 200.0, height: 200.0); await tester.pumpWidget( - MaterialApp( - home: Scaffold( - body: Center( - child: InteractiveViewer( - transformationController: transformationController, - scaleEnabled: false, - onInteractionStart: (ScaleStartDetails details) { - calledStart = true; - }, - onInteractionUpdate: (ScaleUpdateDetails details) { - calledUpdate = true; - }, - onInteractionEnd: (ScaleEndDetails details) { - calledEnd = true; - }, - child: const SizedBox(width: 200.0, height: 200.0), - ), - ), + Center( + child: InteractiveViewer( + transformationController: transformationController, + scaleEnabled: false, + onInteractionStart: (ScaleStartDetails details) { + calledStart = true; + }, + onInteractionUpdate: (ScaleUpdateDetails details) { + calledUpdate = true; + }, + onInteractionEnd: (ScaleEndDetails details) { + calledEnd = true; + }, + child: sizedBox, ), ), ); - final Offset childOffset = tester.getTopLeft(find.byType(SizedBox)); + final Offset childOffset = tester.getTopLeft(find.byWidget(sizedBox)); final childInterior = Offset(childOffset.dx + 20.0, childOffset.dy + 20.0); final TestGesture gesture = await tester.startGesture( childOffset, diff --git a/packages/flutter/test/widgets/semantics_test.dart b/packages/flutter/test/widgets/semantics_test.dart index 0bdd401f61112..5e9a55e902884 100644 --- a/packages/flutter/test/widgets/semantics_test.dart +++ b/packages/flutter/test/widgets/semantics_test.dart @@ -879,13 +879,13 @@ void main() { await tester.pumpWidget( Semantics( container: true, - accessiblityFocusBlockType: AccessiblityFocusBlockType.blockSubtree, + accessibilityFocusBlockType: AccessibilityFocusBlockType.blockSubtree, child: Column( children: [ // If the child set blockSubTreeAccessibilityFocus to `none`, it's still blcok because its parent. Semantics( container: true, - accessiblityFocusBlockType: AccessiblityFocusBlockType.none, + accessibilityFocusBlockType: AccessibilityFocusBlockType.none, customSemanticsActions: { const CustomSemanticsAction(label: 'action1'): () {}, }, @@ -944,12 +944,12 @@ void main() { await tester.pumpWidget( Semantics( container: true, - accessiblityFocusBlockType: AccessiblityFocusBlockType.blockNode, + accessibilityFocusBlockType: AccessibilityFocusBlockType.blockNode, child: Column( children: [ Semantics( container: true, - accessiblityFocusBlockType: AccessiblityFocusBlockType.none, + accessibilityFocusBlockType: AccessibilityFocusBlockType.none, customSemanticsActions: { const CustomSemanticsAction(label: 'action1'): () {}, }, @@ -1004,7 +1004,7 @@ void main() { children: [ Semantics( container: true, - accessiblityFocusBlockType: AccessiblityFocusBlockType.blockNode, + accessibilityFocusBlockType: AccessibilityFocusBlockType.blockNode, label: 'node1', child: const SizedBox(width: 10, height: 10), ), @@ -1050,7 +1050,7 @@ void main() { child: Semantics( label: 'root', child: Semantics( - accessiblityFocusBlockType: AccessiblityFocusBlockType.blockNode, + accessibilityFocusBlockType: AccessibilityFocusBlockType.blockNode, label: 'semantics label 0', child: Column( children: [ @@ -1105,7 +1105,7 @@ void main() { child: Semantics( label: 'root', child: Semantics( - accessiblityFocusBlockType: AccessiblityFocusBlockType.blockSubtree, + accessibilityFocusBlockType: AccessibilityFocusBlockType.blockSubtree, label: 'semantics label 0', child: Column( children: [ @@ -1151,7 +1151,7 @@ void main() { await tester.pumpWidget( Semantics( container: true, - accessiblityFocusBlockType: AccessiblityFocusBlockType.blockSubtree, + accessibilityFocusBlockType: AccessibilityFocusBlockType.blockSubtree, focused: true, customSemanticsActions: { const CustomSemanticsAction(label: 'action1'): () {}, diff --git a/packages/flutter_test/lib/src/controller.dart b/packages/flutter_test/lib/src/controller.dart index 9284d685783fc..cb612582e2e59 100644 --- a/packages/flutter_test/lib/src/controller.dart +++ b/packages/flutter_test/lib/src/controller.dart @@ -1031,6 +1031,20 @@ abstract class WidgetController { // INTERACTION + /// Sends a 'handleScrollToTop' message to the test application via the + /// [SystemChannels.statusBar] channel, to simulate an iOS status bar tap + /// event. + void simulateStatusBarTap() { + final ByteData message = const JSONMethodCodec().encodeMethodCall( + const MethodCall('handleScrollToTop'), + ); + binding.defaultBinaryMessenger.handlePlatformMessage( + SystemChannels.statusBar.name, + message, + (ByteData? data) {}, + ); + } + /// Dispatch a pointer down / pointer up sequence at the center of /// the given widget, assuming it is exposed. /// diff --git a/packages/flutter_test/pubspec.yaml b/packages/flutter_test/pubspec.yaml index 50d1eb9abe870..f77aed6533d3d 100644 --- a/packages/flutter_test/pubspec.yaml +++ b/packages/flutter_test/pubspec.yaml @@ -17,8 +17,8 @@ dependencies: # We depend on very specific internal implementation details of the # 'test' package, which change between versions, so when upgrading # this, make sure the tests are still running correctly. - test_api: 0.7.8 - matcher: 0.12.18 + test_api: 0.7.10 + matcher: 0.12.19 # Used by golden file comparator path: 1.9.1 @@ -47,4 +47,4 @@ dev_dependencies: flutter_driver: sdk: flutter -# PUBSPEC CHECKSUM: pbetap +# PUBSPEC CHECKSUM: tcn62e diff --git a/packages/flutter_tools/bin/xcode_backend.dart b/packages/flutter_tools/bin/xcode_backend.dart index e32980a357a4d..54801b9f43052 100644 --- a/packages/flutter_tools/bin/xcode_backend.dart +++ b/packages/flutter_tools/bin/xcode_backend.dart @@ -722,6 +722,14 @@ class Context { ); } + // Shorebird-specific build-trace plumbing: mac.dart sets + // SHOREBIRD_TRACE_FILE in the Xcode build environment; here (running + // as an Xcode build phase script) we forward it to flutter assemble. + final String? shorebirdTraceFile = environment['SHOREBIRD_TRACE_FILE']; + if (shorebirdTraceFile != null && shorebirdTraceFile.isNotEmpty) { + flutterArgs.add('--shorebird-trace-file=$shorebirdTraceFile'); + } + if (environment['CODE_SIZE_DIRECTORY'] != null && environment['CODE_SIZE_DIRECTORY']!.isNotEmpty) { flutterArgs.add('-dCodeSizeDirectory=${environment['CODE_SIZE_DIRECTORY']}'); diff --git a/packages/flutter_tools/gradle/aar_init_script.gradle b/packages/flutter_tools/gradle/aar_init_script.gradle index 0d43a9b103fd2..0abc801a2fb62 100644 --- a/packages/flutter_tools/gradle/aar_init_script.gradle +++ b/packages/flutter_tools/gradle/aar_init_script.gradle @@ -42,7 +42,7 @@ void configureProject(Project project, String outputDir) { return } - String storageUrl = System.getenv('FLUTTER_STORAGE_BASE_URL') ?: "https://storage.googleapis.com" + String storageUrl = System.getenv('FLUTTER_STORAGE_BASE_URL') ?: "https://download.shorebird.dev" String engineRealm = Paths.get(getFlutterRoot(project), "bin", "cache", "engine.realm") .toFile().text.trim() diff --git a/packages/flutter_tools/gradle/build.gradle.kts b/packages/flutter_tools/gradle/build.gradle.kts index 31fc5cbaedc50..84a0f8a6e2ca8 100644 --- a/packages/flutter_tools/gradle/build.gradle.kts +++ b/packages/flutter_tools/gradle/build.gradle.kts @@ -64,6 +64,7 @@ dependencies { // * ndkVersion in FlutterExtension in packages/flutter_tools/gradle/src/main/kotlin/FlutterExtension.kt compileOnly("com.android.tools.build:gradle:8.11.1") + implementation("org.yaml:snakeyaml:2.0") testImplementation(kotlin("test")) testImplementation("com.android.tools.build:gradle:8.11.1") testImplementation("org.mockito:mockito-core:5.8.0") diff --git a/packages/flutter_tools/gradle/shorebird_trace_init.gradle b/packages/flutter_tools/gradle/shorebird_trace_init.gradle new file mode 100644 index 0000000000000..8f469f87397bf --- /dev/null +++ b/packages/flutter_tools/gradle/shorebird_trace_init.gradle @@ -0,0 +1,159 @@ +// Emits Chrome Trace Event Format events for every Gradle task so the +// Shorebird build trace (`flutter build ... --shorebird-trace`) can show +// per-task timings alongside flutter tool + flutter assemble spans. +// +// This file is Shorebird-specific โ€” it exists in the Shorebird fork of +// Flutter and has no equivalent upstream. Activated by passing +// `-I=` together with `-Pshorebird.gradle-trace-file=`. +// Output file is consumed by Flutter's gradle.dart and merged into the +// main trace. +// +// Events land on tid=4 (flutter tool = 1, native build outer = 2, flutter +// assemble = 3) so Perfetto shows a clean per-tier layout. + +import groovy.json.JsonOutput +import org.gradle.api.Task +import org.gradle.api.execution.TaskExecutionListener +import org.gradle.api.tasks.TaskState + +def traceFilePath = gradle.startParameter.projectProperties['shorebird.gradle-trace-file'] +if (!traceFilePath) { + traceFilePath = System.getProperty('shorebird.gradle-trace-file') +} +if (!traceFilePath) { + return +} + +// Flow id shared with flutter_tool so Perfetto draws an arrow from the +// parent "gradle " span into our first per-task event. Missing +// when flutter_tool is older than the flow-id plumbing; that's fine, +// the init script just skips the flow-end event in that case. +def gradleFlowIdRaw = gradle.startParameter.projectProperties['shorebird.gradle-trace-flow-id'] +def gradleFlowId = gradleFlowIdRaw ? gradleFlowIdRaw.toLong() : null + +// Real pid of this Gradle JVM โ€” Gradle tasks belong to this process, so +// emitting spans on it matches the process topology. Named via a +// `process_name` metadata event below so Perfetto labels the row +// "gradle" rather than showing a bare number. +def gradlePid = ProcessHandle.current().pid() +def gradleTid = 1 + +def events = Collections.synchronizedList([ + [ + name: 'process_name', + ph: 'M', + pid: gradlePid, + args: [name: 'gradle'], + ], + [ + name: 'thread_name', + ph: 'M', + pid: gradlePid, + tid: gradleTid, + args: [name: 'gradle tasks'], + ], +]) +def firstTaskFlowEmitted = new java.util.concurrent.atomic.AtomicBoolean(false) +def startTimesMicros = Collections.synchronizedMap([:]) + +gradle.addListener(new TaskExecutionListener() { + @Override + void beforeExecute(Task task) { + startTimesMicros[task.path] = System.currentTimeMillis() * 1000L + } + + @Override + void afterExecute(Task task, TaskState state) { + def start = startTimesMicros.remove(task.path) + if (start == null) return + def end = System.currentTimeMillis() * 1000L + def dur = Math.max(0L, end - start) + // Classify the task path into a small set of "kinds" so downstream + // summarizers can bucket without retaining names. Match against the + // task's simple name (last colon segment) so plugin names like + // `:package_info_plus:...` don't accidentally trigger the `packaging` + // bucket. + // + // The kind strings below are a wire-format contract with + // shorebird_cli (which buckets its summary by these values). + // KEEP IN SYNC with the `GradleTaskKind` enum in the + // `shorebird_build_trace` package; adding a new kind means + // landing the matching enum value there AND teaching + // shorebird_cli to bucket it, otherwise it silently falls into + // `other`. + def path = task.path + def lastColon = path.lastIndexOf(':') + def simpleName = (lastColon >= 0 ? path.substring(lastColon + 1) : path).toLowerCase() + + // Order matters: the first regex that matches wins. `kotlin_compile` + // must precede `java_compile` (both match `compile*`). `java_compile` + // must precede the `scaffold` bucket's `prepare*` because AGP's + // javaPreCompileRelease is scaffolding-shaped but semantically javac. + // `flutter_gradle_plugin` must precede `packaging` so tasks like + // `packageFlutterBuild` don't fall through to the `package*` bucket. + def kindRules = [ + [~/^compile.*kotlin.*/, 'kotlin_compile'], + [~/(^compile.*(java|jni).*|.*precompile.*)/, 'java_compile'], + [~/.*aidl.*/, 'aidl'], + [~/(^minify.*|.*r8.*)/, 'r8_minify'], + [~/.*dex.*/, 'dex'], + [~/^lint.*/, 'lint'], + [~/(.*jnilibs.*|.*nativelibs.*|.*link.*native.*)/, 'native_link'], + [~/.*(processresources|mergeresources|mergemanifest|rfile|parserelease|mergerelease|generaterelease).*/, 'resources'], + [~/(^compileflutter.*|.*flutterbuild.*|^flutter.*)/, 'flutter_gradle_plugin'], + [~/^package.*/, 'packaging'], + [~/.*bundle.*/, 'bundle'], + [~/.*transform.*/, 'transform'], + // Catch-all for Gradle's per-plugin / per-variant scaffolding + // (metadata files, proguard rule export, pre-/post-compile + // bookkeeping). Individually small but the long tail adds up on + // plugin-heavy apps. + [~/(.*aarmetadata.*|.*proguard.*|.*validate.*|^check.*|^prepare.*|^generate.*|^copy.*)/, 'gradle_scaffold'], + ] + def kind = kindRules.find { simpleName ==~ it[0] }?.getAt(1) ?: 'other' + + // The task "owner" is the first colon-separated segment. For + // subproject tasks this is typically the plugin name (e.g. + // `:camera_android`). Kept for local debug โ€” the privacy-safe + // summary that Shorebird writes aggregates this out. + def owner = path.startsWith(':') ? path.substring(1).split(':')[0] : '' + + // Emit the flow-end event on the first task, connecting back to + // flutter_tool's "gradle " span. Atomic so we emit at most + // one across concurrent task callbacks. + if (gradleFlowId != null && firstTaskFlowEmitted.compareAndSet(false, true)) { + events.add([ + ph: 'f', + name: 'spawn', + cat: 'flow', + id: gradleFlowId, + ts: start, + pid: gradlePid, + tid: gradleTid, + bp: 'e', + ]) + } + events.add([ + name: path, + cat: 'gradle_task', + ph: 'X', + ts: start, + dur: dur, + pid: gradlePid, + tid: gradleTid, + args: [ + kind: kind, + owner: owner, + skipped: state.skipped, + upToDate: state.upToDate, + fromCache: state.skipMessage == 'FROM-CACHE', + ], + ]) + } +}) + +gradle.buildFinished { + def f = new File(traceFilePath) + f.parentFile?.mkdirs() + f.text = JsonOutput.toJson(events) +} diff --git a/packages/flutter_tools/gradle/src/main/kotlin/FlutterPlugin.kt b/packages/flutter_tools/gradle/src/main/kotlin/FlutterPlugin.kt index 9dc036df7b154..c253de96ab3dc 100644 --- a/packages/flutter_tools/gradle/src/main/kotlin/FlutterPlugin.kt +++ b/packages/flutter_tools/gradle/src/main/kotlin/FlutterPlugin.kt @@ -597,6 +597,8 @@ class FlutterPlugin : Plugin { val dartDefinesValue: String? = project.findProperty("dart-defines")?.toString() val performanceMeasurementFileValue: String? = project.findProperty("performance-measurement-file")?.toString() + val shorebirdTraceFileValue: String? = + project.findProperty("shorebird-trace-file")?.toString() val codeSizeDirectoryValue: String? = project.findProperty("code-size-directory")?.toString() val deferredComponentsValue: Boolean = @@ -694,6 +696,7 @@ class FlutterPlugin : Plugin { dartObfuscation = dartObfuscationValue dartDefines = dartDefinesValue performanceMeasurementFile = performanceMeasurementFileValue + shorebirdTraceFile = shorebirdTraceFileValue codeSizeDirectory = codeSizeDirectoryValue deferredComponents = deferredComponentsValue validateDeferredComponents = validateDeferredComponentsValue diff --git a/packages/flutter_tools/gradle/src/main/kotlin/FlutterPluginConstants.kt b/packages/flutter_tools/gradle/src/main/kotlin/FlutterPluginConstants.kt index f9525ef479e31..4cc13207211cb 100644 --- a/packages/flutter_tools/gradle/src/main/kotlin/FlutterPluginConstants.kt +++ b/packages/flutter_tools/gradle/src/main/kotlin/FlutterPluginConstants.kt @@ -19,7 +19,7 @@ object FlutterPluginConstants { const val INTERMEDIATES_DIR = "intermediates" const val FLUTTER_STORAGE_BASE_URL = "FLUTTER_STORAGE_BASE_URL" - const val DEFAULT_MAVEN_HOST = "https://storage.googleapis.com" + const val DEFAULT_MAVEN_HOST = "https://download.shorebird.dev" /** Maps platforms to ABI architectures. */ @JvmStatic val PLATFORM_ARCH_MAP = diff --git a/packages/flutter_tools/gradle/src/main/kotlin/tasks/BaseFlutterTask.kt b/packages/flutter_tools/gradle/src/main/kotlin/tasks/BaseFlutterTask.kt index 1858a139d0e96..96331ef87c94d 100644 --- a/packages/flutter_tools/gradle/src/main/kotlin/tasks/BaseFlutterTask.kt +++ b/packages/flutter_tools/gradle/src/main/kotlin/tasks/BaseFlutterTask.kt @@ -111,6 +111,12 @@ open class BaseFlutterTask : DefaultTask() { @Input var performanceMeasurementFile: String? = null + // Shorebird-specific: path for the Shorebird build trace. Prefixed so + // it stands out as fork-only surface in this otherwise upstream file. + @Optional + @Input + var shorebirdTraceFile: String? = null + @Optional @Input var deferredComponents: Boolean? = null diff --git a/packages/flutter_tools/gradle/src/main/kotlin/tasks/BaseFlutterTaskHelper.kt b/packages/flutter_tools/gradle/src/main/kotlin/tasks/BaseFlutterTaskHelper.kt index 2d16640d324fd..2f5fac2de3a76 100644 --- a/packages/flutter_tools/gradle/src/main/kotlin/tasks/BaseFlutterTaskHelper.kt +++ b/packages/flutter_tools/gradle/src/main/kotlin/tasks/BaseFlutterTaskHelper.kt @@ -110,6 +110,9 @@ object BaseFlutterTaskHelper { baseFlutterTask.performanceMeasurementFile?.let { args("--performance-measurement-file=$it") } + baseFlutterTask.shorebirdTraceFile?.let { + args("--shorebird-trace-file=$it") + } args("-dTargetFile=${baseFlutterTask.targetPath}") args("-dTargetPlatform=android") args("-dBuildMode=${baseFlutterTask.buildMode}") diff --git a/packages/flutter_tools/lib/src/android/gradle.dart b/packages/flutter_tools/lib/src/android/gradle.dart index b132b4cbe530a..c5be500d0f88d 100644 --- a/packages/flutter_tools/lib/src/android/gradle.dart +++ b/packages/flutter_tools/lib/src/android/gradle.dart @@ -29,6 +29,7 @@ import '../convert.dart'; import '../flutter_manifest.dart'; import '../globals.dart' as globals; import '../project.dart'; +import '../shorebird/android_build_trace_session.dart'; import 'android_builder.dart'; import 'android_studio.dart'; import 'gradle_errors.dart'; @@ -276,6 +277,7 @@ class AndroidGradleBuilder implements AndroidBuilder { VoidCallback? postRunTask, int? maxRetries, _OutputParser? outputParser, + void Function(Process process)? onStart, }) async { final bool usesAndroidX = isAppUsingAndroidX(project.android.hostAppGradleRoot); final String? agpVersion = gradle.getAgpVersion( @@ -352,6 +354,7 @@ class AndroidGradleBuilder implements AndroidBuilder { allowReentrantFlutter: true, environment: _java?.environment, mapFunction: consumeLog, + onStart: onStart, ); } on ProcessException catch (exception) { consumeLog(exception.toString()); @@ -479,7 +482,12 @@ class AndroidGradleBuilder implements AndroidBuilder { return; } - // Assembly work starts here. + final AndroidBuildTraceSession? traceSession = AndroidBuildTraceSession.maybeStart( + androidBuildInfo, + _fileSystem, + project.android.buildDirectory, + ); + final BuildInfo buildInfo = androidBuildInfo.buildInfo; final String assembleTask = isBuildingBundle ? getBundleTaskFor(buildInfo) @@ -556,6 +564,7 @@ class AndroidGradleBuilder implements AndroidBuilder { } } options.addAll(androidBuildInfo.buildInfo.toGradleConfig()); + options.addAll(traceSession?.extraGradleOptions(assembleTask) ?? const []); if (buildInfo.fileSystemRoots.isNotEmpty) { options.add('-Pfilesystem-roots=${buildInfo.fileSystemRoots.join('|')}'); } @@ -569,8 +578,10 @@ class AndroidGradleBuilder implements AndroidBuilder { final int exitCode = await _runGradleTask( assembleTask, preRunTask: () { + traceSession?.onGradleAboutToStart(); sw = Stopwatch()..start(); }, + onStart: (process) => traceSession?.onGradleSpawn(process), postRunTask: () { final Duration elapsedDuration = sw.elapsed; _analytics.send( @@ -588,13 +599,21 @@ class AndroidGradleBuilder implements AndroidBuilder { gradleExecutablePath: gradleExecutablePath, ); + traceSession?.onGradleFinished(assembleTask); + if (exitCode != 0) { + traceSession?.abortOnGradleFailure(); throwToolExit( 'Gradle task $assembleTask failed with exit code $exitCode', exitCode: exitCode, ); } + traceSession?.finish( + buildTarget: isBuildingBundle ? 'appbundle' : 'apk', + printStatus: _logger.printStatus, + ); + if (isBuildingBundle) { final File bundleFile = findBundleFile(project, buildInfo, _logger, _analytics); diff --git a/packages/flutter_tools/lib/src/base/build.dart b/packages/flutter_tools/lib/src/base/build.dart index bb967d7111be4..312be7b837b85 100644 --- a/packages/flutter_tools/lib/src/base/build.dart +++ b/packages/flutter_tools/lib/src/base/build.dart @@ -8,7 +8,6 @@ import '../artifacts.dart'; import '../build_info.dart'; import '../darwin/darwin.dart'; import '../macos/xcode.dart'; - import 'file_system.dart'; import 'logger.dart'; import 'process.dart'; @@ -130,7 +129,27 @@ class AOTSnapshotter { final Directory outputDir = _fileSystem.directory(outputPath); outputDir.createSync(recursive: true); - final genSnapshotArgs = ['--deterministic']; + // Currently we only use the linker on iOS, but we will eventually split out + // the concept of "optimizes patch snapshot" from "uses linker" and probably + // only uses the linker on iOS, but optimize patch snapshots everywhere. + // TODO(eseidel): TargetPlatform.darwin doesn't use the linker. + bool usesLinker = (platform == TargetPlatform.ios || platform == TargetPlatform.darwin); + final dumpLinkInfoArgs = [ + // Shorebird dumps the class table information during snapshot compilation which is later used during linking. + '--print_class_table_link_debug_info_to=${_fileSystem.path.join(outputDir.parent.path, 'App.class_table.json')}', + '--print_class_table_link_info_to=${_fileSystem.path.join(outputDir.parent.path, 'App.ct.link')}', + '--print_field_table_link_debug_info_to=${_fileSystem.path.join(outputDir.parent.path, 'App.field_table.json')}', + '--print_field_table_link_info_to=${_fileSystem.path.join(outputDir.parent.path, 'App.ft.link')}', + '--print_dispatch_table_link_debug_info_to=${_fileSystem.path.join(outputDir.parent.path, 'App.dispatch_table.json')}', + '--print_dispatch_table_link_info_to=${_fileSystem.path.join(outputDir.parent.path, 'App.dt.link')}', + ]; + + final genSnapshotArgs = [ + // Shorebird uses --deterministic to improve snapshot stability and increase linking. + '--deterministic', + // Only save LinkInfo if we're using the linker. + if (usesLinker) ...dumpLinkInfoArgs, + ]; final bool targetingApplePlatform = platform == TargetPlatform.ios || platform == TargetPlatform.darwin; diff --git a/packages/flutter_tools/lib/src/base/net.dart b/packages/flutter_tools/lib/src/base/net.dart index e64da52bffcf1..4811a6c5ebc98 100644 --- a/packages/flutter_tools/lib/src/base/net.dart +++ b/packages/flutter_tools/lib/src/base/net.dart @@ -7,6 +7,7 @@ import 'dart:async'; import 'package:meta/meta.dart'; import '../convert.dart'; +import '../shorebird/network_trace_span.dart'; import 'common.dart'; import 'file_system.dart'; import 'io.dart'; @@ -87,6 +88,8 @@ class Net { Future _attempt(Uri url, {IOSink? destSink, bool onlyHeaders = false}) async { assert(onlyHeaders || destSink != null); _logger.printTrace('Downloading: $url'); + final traceSpan = NetworkTraceSpan.start(url: url, onlyHeaders: onlyHeaders); + final HttpClient httpClient = _httpClientFactory(); HttpClientRequest request; HttpClientResponse? response; @@ -98,6 +101,7 @@ class Net { } response = await request.close(); } on ArgumentError catch (error) { + traceSpan.record(errorKind: 'ArgumentError'); final String? overrideUrl = _platform.environment[kFlutterStorageBaseUrl]; if (overrideUrl != null && url.toString().contains(overrideUrl)) { _logger.printError(error.toString()); @@ -112,6 +116,7 @@ class Net { _logger.printError(error.toString()); rethrow; } on HandshakeException catch (error) { + traceSpan.record(errorKind: 'HandshakeException'); _logger.printTrace(error.toString()); throwToolExit( 'Could not authenticate download server. You may be experiencing a man-in-the-middle attack,\n' @@ -120,29 +125,35 @@ class Net { exitCode: kNetworkProblemExitCode, ); } on SocketException catch (error) { + traceSpan.record(errorKind: 'SocketException'); _logger.printTrace('Download error: $error'); return false; } on HttpException catch (error) { + traceSpan.record(errorKind: 'HttpException'); _logger.printTrace('Download error: $error'); return false; } + final int statusCode = response.statusCode; + // If we're making a HEAD request, we're only checking to see if the URL is // valid. if (onlyHeaders) { - return response.statusCode == HttpStatus.ok; + traceSpan.record(statusCode: statusCode); + return statusCode == HttpStatus.ok; } - if (response.statusCode != HttpStatus.ok) { - if (response.statusCode > 0 && response.statusCode < 500) { + if (statusCode != HttpStatus.ok) { + traceSpan.record(statusCode: statusCode); + if (statusCode > 0 && statusCode < 500) { throwToolExit( 'Download failed.\n' 'URL: $url\n' - 'Error: ${response.statusCode} ${response.reasonPhrase}', + 'Error: $statusCode ${response.reasonPhrase}', exitCode: kNetworkProblemExitCode, ); } // 5xx errors are server errors and we can try again - _logger.printTrace('Download error: ${response.statusCode} ${response.reasonPhrase}'); + _logger.printTrace('Download error: $statusCode ${response.reasonPhrase}'); return false; } _logger.printTrace('Received response from server, collecting bytes...'); @@ -156,6 +167,9 @@ class Net { } finally { await destSink?.flush(); await destSink?.close(); + // Record after the body has been fully drained so `dur` reflects + // end-to-end download time, not just time-to-first-byte. + traceSpan.record(statusCode: statusCode); } } } diff --git a/packages/flutter_tools/lib/src/base/process.dart b/packages/flutter_tools/lib/src/base/process.dart index 187f5bfa8b728..35813f51983c3 100644 --- a/packages/flutter_tools/lib/src/base/process.dart +++ b/packages/flutter_tools/lib/src/base/process.dart @@ -238,6 +238,7 @@ abstract class ProcessUtils { RegExp? stdoutErrorMatcher, StringConverter? mapFunction, Map? environment, + void Function(Process process)? onStart, }); bool exitsHappySync(List cli, {Map? environment}); @@ -554,6 +555,7 @@ class _DefaultProcessUtils implements ProcessUtils { RegExp? stdoutErrorMatcher, StringConverter? mapFunction, Map? environment, + void Function(Process process)? onStart, }) async { final Process process = await start( cmd, @@ -561,6 +563,7 @@ class _DefaultProcessUtils implements ProcessUtils { allowReentrantFlutter: allowReentrantFlutter, environment: environment, ); + onStart?.call(process); final StreamSubscription stdoutSubscription = process.stdout .transform(utf8LineDecoder) .where((String line) => filter == null || filter.hasMatch(line)) diff --git a/packages/flutter_tools/lib/src/build_info.dart b/packages/flutter_tools/lib/src/build_info.dart index 6867be4c62a33..091e086262591 100644 --- a/packages/flutter_tools/lib/src/build_info.dart +++ b/packages/flutter_tools/lib/src/build_info.dart @@ -44,6 +44,7 @@ class BuildInfo { List? dartExperiments, required this.treeShakeIcons, this.performanceMeasurementFile, + this.shorebirdTraceFilePath, required this.packageConfigPath, this.codeSizeDirectory, this.androidGradleDaemon = true, @@ -135,6 +136,12 @@ class BuildInfo { /// rerun tasks. final String? performanceMeasurementFile; + /// Path to the Shorebird build-trace output file. When non-null, the + /// Shorebird-specific build-trace plumbing emits a Chrome Trace Event + /// Format JSON file here. Named `shorebird` to keep the fork's + /// surface area visibly distinct from upstream Flutter. + final String? shorebirdTraceFilePath; + /// If provided, an output directory where one or more v8-style heap snapshots /// will be written for code size profiling. final String? codeSizeDirectory; diff --git a/packages/flutter_tools/lib/src/build_system/build_system.dart b/packages/flutter_tools/lib/src/build_system/build_system.dart index 7e26475c408a8..82f406f82c099 100644 --- a/packages/flutter_tools/lib/src/build_system/build_system.dart +++ b/packages/flutter_tools/lib/src/build_system/build_system.dart @@ -882,6 +882,7 @@ class _BuildInstance { Future _invokeInternal(Node node) async { final PoolResource resource = await resourcePool.request(); + final int startTimeMicroseconds = DateTime.now().microsecondsSinceEpoch; final stopwatch = Stopwatch()..start(); var succeeded = true; var skipped = false; @@ -982,6 +983,7 @@ class _BuildInstance { skipped: skipped, succeeded: succeeded, analyticsName: node.target.analyticsName, + startTimeMicroseconds: startTimeMicroseconds, ); } return succeeded; @@ -1011,6 +1013,7 @@ class PerformanceMeasurement { required this.skipped, required this.succeeded, required this.analyticsName, + required this.startTimeMicroseconds, }); final int elapsedMilliseconds; @@ -1018,6 +1021,9 @@ class PerformanceMeasurement { final bool skipped; final bool succeeded; final String analyticsName; + + /// Wall-clock start time in microseconds since epoch. + final int startTimeMicroseconds; } /// Check if there are any dependency cycles in the target. diff --git a/packages/flutter_tools/lib/src/build_system/targets/assets.dart b/packages/flutter_tools/lib/src/build_system/targets/assets.dart index f9ebbae2045b5..0b7be7e672975 100644 --- a/packages/flutter_tools/lib/src/build_system/targets/assets.dart +++ b/packages/flutter_tools/lib/src/build_system/targets/assets.dart @@ -13,6 +13,7 @@ import '../../dart/package_map.dart'; import '../../devfs.dart'; import '../../flutter_manifest.dart'; import '../../isolated/native_assets/dart_hook_result.dart'; +import '../../shorebird/shorebird_yaml.dart'; import '../build_system.dart'; import '../depfile.dart'; import '../exceptions.dart'; @@ -162,6 +163,17 @@ Future copyAssets( } if (doCopy) { await (content.file as File).copy(file.path); + if (file.basename == 'shorebird.yaml') { + try { + updateShorebirdYaml( + environment.defines[kFlavor], + file.path, + environment: environment.platform.environment, + ); + } on Exception catch (error) { + throw Exception('Failed to generate shorebird configuration. Error: $error'); + } + } } } else { await file.writeAsBytes(await entry.value.content.contentsAsBytes()); diff --git a/packages/flutter_tools/lib/src/build_system/targets/common.dart b/packages/flutter_tools/lib/src/build_system/targets/common.dart index 63eb1c736e825..e67e646bebf1b 100644 --- a/packages/flutter_tools/lib/src/build_system/targets/common.dart +++ b/packages/flutter_tools/lib/src/build_system/targets/common.dart @@ -504,3 +504,38 @@ abstract final class Lipo { } } } + +/// For managing the supplementary linking files for Shorebird. +abstract final class LinkSupplement { + static Future create( + Environment environment, { + required String inputBuildDir, + required String outputBuildDir, + }) async { + // If the shorebird directory exists, delete it first. + final Directory shorebirdDir = environment.fileSystem.directory( + environment.fileSystem.path.join(outputBuildDir, 'shorebird'), + ); + if (shorebirdDir.existsSync()) { + shorebirdDir.deleteSync(recursive: true); + } + + void maybeCopy(String name) { + final File file = environment.fileSystem.file( + environment.fileSystem.path.join(inputBuildDir, name), + ); + if (file.existsSync()) { + file.copySync(environment.fileSystem.path.join(shorebirdDir.path, name)); + } + } + + // Copy the link information (generated by gen_snapshot) + // into the shorebird directory. + maybeCopy('App.ct.link'); + maybeCopy('App.class_table.json'); + maybeCopy('App.dt.link'); + maybeCopy('App.dispatch_table.json'); + maybeCopy('App.ft.link'); + maybeCopy('App.field_table.json'); + } +} diff --git a/packages/flutter_tools/lib/src/build_system/targets/ios.dart b/packages/flutter_tools/lib/src/build_system/targets/ios.dart index a6116632fd46c..9a91c94beeece 100644 --- a/packages/flutter_tools/lib/src/build_system/targets/ios.dart +++ b/packages/flutter_tools/lib/src/build_system/targets/ios.dart @@ -143,6 +143,12 @@ abstract class AotAssemblyBase extends Target { // Don't fail if the dSYM wasn't created (i.e. during a debug build). skipMissingInputs: true, ); + + await LinkSupplement.create( + environment, + inputBuildDir: buildOutputPath, + outputBuildDir: getIosBuildDirectory(), + ); } } diff --git a/packages/flutter_tools/lib/src/build_system/targets/macos.dart b/packages/flutter_tools/lib/src/build_system/targets/macos.dart index dbffaa465c994..c0c382c934f48 100644 --- a/packages/flutter_tools/lib/src/build_system/targets/macos.dart +++ b/packages/flutter_tools/lib/src/build_system/targets/macos.dart @@ -344,6 +344,12 @@ class CompileMacOSFramework extends Target { // Don't fail if the dSYM wasn't created (i.e. during a debug build). skipMissingInputs: true, ); + + await LinkSupplement.create( + environment, + inputBuildDir: buildOutputPath, + outputBuildDir: getMacOSBuildDirectory(), + ); } @override diff --git a/packages/flutter_tools/lib/src/build_system/targets/web.dart b/packages/flutter_tools/lib/src/build_system/targets/web.dart index 1606256ddc7b0..2f7ccbae1dd31 100644 --- a/packages/flutter_tools/lib/src/build_system/targets/web.dart +++ b/packages/flutter_tools/lib/src/build_system/targets/web.dart @@ -726,6 +726,13 @@ _flutter.buildConfig = ${jsonEncode(buildConfig)}; final String buildConfig = buildConfigString(environment); + // Extract web-define variables from the environment. These are stored with + // the [kWebDefinePrefix] prefix by [WebBuilder.buildWeb]. + final webDefines = { + for (final MapEntry(:key, :value) in environment.defines.entries) + if (key.startsWith(kWebDefinePrefix)) key.substring(kWebDefinePrefix.length): value, + }; + // Insert a random hash into the requests for service_worker.js. This is not a content hash, // because it would need to be the hash for the entire bundle and not just the resource // in question. @@ -737,6 +744,8 @@ _flutter.buildConfig = ${jsonEncode(buildConfig)}; serviceWorkerVersion: serviceWorkerVersion, flutterJsFile: flutterJsFile, buildConfig: buildConfig, + logger: environment.logger, + webDefines: webDefines, ); final File outputFlutterBootstrapJs = fileSystem.file( @@ -760,6 +769,8 @@ _flutter.buildConfig = ${jsonEncode(buildConfig)}; flutterJsFile: flutterJsFile, buildConfig: buildConfig, flutterBootstrapJs: bootstrapContent, + logger: environment.logger, + webDefines: webDefines, ); final File outputIndexHtml = fileSystem.file( fileSystem.path.join(environment.outputDir.path, relativePath), diff --git a/packages/flutter_tools/lib/src/cache.dart b/packages/flutter_tools/lib/src/cache.dart index 275fb098f4f83..2cac940fccfb1 100644 --- a/packages/flutter_tools/lib/src/cache.dart +++ b/packages/flutter_tools/lib/src/cache.dart @@ -34,6 +34,7 @@ import 'base/user_messages.dart'; import 'convert.dart'; import 'features.dart'; +const kShorebirdStorageUrl = 'https://download.shorebird.dev'; const kFlutterRootEnvironmentVariableName = 'FLUTTER_ROOT'; // should point to //flutter/ (root of flutter/flutter repo) const kFlutterEngineEnvironmentVariableName = @@ -517,7 +518,7 @@ class Cache { /// The base for URLs that store Flutter engine artifacts that are fetched /// during the installation of the Flutter SDK. /// - /// By default the base URL is https://storage.googleapis.com. However, if + /// By default the base URL is https://download.shorebird.dev. However, if /// `FLUTTER_STORAGE_BASE_URL` environment variable ([kFlutterStorageBaseUrl]) /// is provided, the environment variable value is returned instead. /// @@ -529,9 +530,13 @@ class Cache { String? overrideUrl = _platform.environment[kFlutterStorageBaseUrl]; if (overrideUrl == null) { return storageRealm.isEmpty - ? 'https://storage.googleapis.com' + ? 'https://download.shorebird.dev' : 'https://storage.googleapis.com/$storageRealm'; } + // Shorebird's artifact proxy is a trusted source. + if (overrideUrl == kShorebirdStorageUrl) { + return overrideUrl; + } // verify that this is a valid URI. overrideUrl = storageRealm.isEmpty ? overrideUrl : '$overrideUrl/$storageRealm'; try { diff --git a/packages/flutter_tools/lib/src/commands/assemble.dart b/packages/flutter_tools/lib/src/commands/assemble.dart index 564aa971a1a20..125b7585e850b 100644 --- a/packages/flutter_tools/lib/src/commands/assemble.dart +++ b/packages/flutter_tools/lib/src/commands/assemble.dart @@ -4,6 +4,7 @@ import 'package:args/args.dart'; import 'package:meta/meta.dart'; +import 'package:shorebird_build_trace/shorebird_build_trace.dart'; import 'package:unified_analytics/unified_analytics.dart'; import '../artifacts.dart'; @@ -112,6 +113,13 @@ class AssembleCommand extends FlutterCommand { 'performance-measurement-file', help: 'Output individual target performance to a JSON file.', ); + argParser.addOption( + 'shorebird-trace-file', + help: + 'Output a Shorebird build trace in Chrome Trace Event Format ' + 'JSON. Shorebird-specific; used by the build-trace plumbing in ' + 'gradle.dart / mac.dart to collect flutter-assemble timings.', + ); argParser.addMultiOption( 'input', abbr: 'i', @@ -394,6 +402,10 @@ class AssembleCommand extends FlutterCommand { final File outFile = globals.fs.file(argumentResults['performance-measurement-file']); writePerformanceData(result.performance.values, outFile); } + if (argumentResults.wasParsed('shorebird-trace-file')) { + final File outFile = globals.fs.file(argumentResults['shorebird-trace-file']); + writeTraceData(result.performance.values, outFile); + } if (argumentResults.wasParsed('depfile')) { final File depfileFile = globals.fs.file(stringArg('depfile')); final depfile = Depfile(result.inputFiles, result.outputFiles); @@ -440,3 +452,35 @@ void writePerformanceData(Iterable measurements, File ou } outFile.writeAsStringSync(json.encode(jsonData)); } + +/// Output build trace data in Chrome Trace Event Format in [outFile]. +/// `flutter assemble` is re-entered as its own process by Gradle / +/// xcode_backend, so its events get their own Perfetto row named +/// `flutter assemble`. Spans for each [PerformanceMeasurement] are +/// emitted using its wall-clock [PerformanceMeasurement.startTimeMicroseconds] +/// (converted to a DateTime; not the stopwatch duration alone) so the +/// timeline aligns with the parent flutter tool's trace when merged. +@visibleForTesting +void writeTraceData(Iterable measurements, File outFile) { + final int pid = currentProcessId(); + final tracer = BuildTracer() + ..addProcessNameMetadata(pid: pid, name: 'flutter assemble') + ..addThreadNameMetadata(pid: pid, tid: 1, name: 'flutter assemble'); + for (final measurement in measurements) { + final start = DateTime.fromMicrosecondsSinceEpoch(measurement.startTimeMicroseconds); + tracer.addCompleteEvent( + name: measurement.analyticsName, + cat: TraceCategory.assemble.wireName, + pid: pid, + tid: 1, + start: start, + end: start.add(Duration(milliseconds: measurement.elapsedMilliseconds)), + args: { + 'target': measurement.target, + 'skipped': measurement.skipped, + 'succeeded': measurement.succeeded, + }, + ); + } + tracer.writeToFile(outFile); +} diff --git a/packages/flutter_tools/lib/src/commands/build_apk.dart b/packages/flutter_tools/lib/src/commands/build_apk.dart index d3f50300804ef..810219ef7810c 100644 --- a/packages/flutter_tools/lib/src/commands/build_apk.dart +++ b/packages/flutter_tools/lib/src/commands/build_apk.dart @@ -31,6 +31,7 @@ class BuildApkCommand extends BuildSubCommand { usesExtraDartFlagOptions(verboseHelp: verboseHelp); addEnableExperimentation(hide: !verboseHelp); addBuildPerformanceFile(hide: !verboseHelp); + usesShorebirdTraceOption(hide: !verboseHelp); usesAnalyzeSizeFlag(); addAndroidSpecificBuildOptions(hide: !verboseHelp); addIgnoreDeprecationOption(); diff --git a/packages/flutter_tools/lib/src/commands/build_appbundle.dart b/packages/flutter_tools/lib/src/commands/build_appbundle.dart index 85a2ef3c71ffd..5d4a39d4f1be6 100644 --- a/packages/flutter_tools/lib/src/commands/build_appbundle.dart +++ b/packages/flutter_tools/lib/src/commands/build_appbundle.dart @@ -33,6 +33,7 @@ class BuildAppBundleCommand extends BuildSubCommand { usesDartDefineOption(); usesExtraDartFlagOptions(verboseHelp: verboseHelp); addBuildPerformanceFile(hide: !verboseHelp); + usesShorebirdTraceOption(hide: !verboseHelp); usesTrackWidgetCreation(verboseHelp: verboseHelp); addEnableExperimentation(hide: !verboseHelp); usesAnalyzeSizeFlag(); diff --git a/packages/flutter_tools/lib/src/commands/build_ios.dart b/packages/flutter_tools/lib/src/commands/build_ios.dart index e783f793b9e7a..6cfb5b75dbc8a 100644 --- a/packages/flutter_tools/lib/src/commands/build_ios.dart +++ b/packages/flutter_tools/lib/src/commands/build_ios.dart @@ -907,6 +907,7 @@ abstract class _BuildIOSSubCommand extends BuildSubCommand { usesExtraDartFlagOptions(verboseHelp: verboseHelp); addEnableExperimentation(hide: !verboseHelp); addBuildPerformanceFile(hide: !verboseHelp); + usesShorebirdTraceOption(hide: !verboseHelp); usesAnalyzeSizeFlag(); argParser.addFlag( 'codesign', diff --git a/packages/flutter_tools/lib/src/commands/build_web.dart b/packages/flutter_tools/lib/src/commands/build_web.dart index 661b77d1b73b8..5406f6e51c0b1 100644 --- a/packages/flutter_tools/lib/src/commands/build_web.dart +++ b/packages/flutter_tools/lib/src/commands/build_web.dart @@ -288,6 +288,7 @@ class BuildWebCommand extends BuildSubCommand { // valid approaches for setting output directory of build artifacts final String? outputDirectoryPath = stringArg('output'); + final Map webDefines = extractWebDefines(); final webBuilder = WebBuilder( logger: globals.logger, processManager: globals.processManager, @@ -305,6 +306,7 @@ class BuildWebCommand extends BuildSubCommand { baseHref: baseHref, staticAssetsUrl: staticAssetsUrl, outputDirectoryPath: outputDirectoryPath, + webDefines: webDefines, ); return FlutterCommandResult.success(); } diff --git a/packages/flutter_tools/lib/src/commands/update_packages.dart b/packages/flutter_tools/lib/src/commands/update_packages.dart index 382e534024c44..41c5d68269e43 100644 --- a/packages/flutter_tools/lib/src/commands/update_packages.dart +++ b/packages/flutter_tools/lib/src/commands/update_packages.dart @@ -171,16 +171,15 @@ class UpdatePackagesCommand extends FlutterCommand { final FlutterProject toolProject = FlutterProject.fromDirectory( rootDirectory.childDirectory('packages').childDirectory('flutter_tools'), ); - // This needs to be special cased, as it is below flutter_tools, so cannot - // be in the flutter pub workspace. + + // This package is intentionally not part of the workspace as it's a rehydrated template. final FlutterProject widgetPreviewScaffoldProject = FlutterProject.fromDirectory( rootProject.directory - .childDirectory('packages') - .childDirectory('flutter_tools') - .childDirectory('test') - .childDirectory('widget_preview_scaffold.shard') + .childDirectory('dev') + .childDirectory('integration_tests') .childDirectory('widget_preview_scaffold'), ); + // This package is intentionally not part of the workspace to test // user-defines in its local pubspec. final Directory hooksUserDefineIntegrationTestDirectory = rootDirectory @@ -215,6 +214,7 @@ class UpdatePackagesCommand extends FlutterCommand { rootDirectory.childDirectory('packages').childDirectory('flutter'), rootDirectory.childDirectory('packages').childDirectory('flutter_test'), rootDirectory.childDirectory('packages').childDirectory('flutter_localizations'), + widgetPreviewScaffoldProject.directory, hooksUserDefineIntegrationTestDirectory, ]) { _updatePubspec(package, deps); diff --git a/packages/flutter_tools/lib/src/http_host_validator.dart b/packages/flutter_tools/lib/src/http_host_validator.dart index 9702f83bffd08..8363221166398 100644 --- a/packages/flutter_tools/lib/src/http_host_validator.dart +++ b/packages/flutter_tools/lib/src/http_host_validator.dart @@ -11,7 +11,7 @@ import 'doctor_validator.dart'; import 'features.dart'; /// Common Flutter HTTP hosts. -const kCloudHost = 'https://storage.googleapis.com/'; +const kCloudHost = 'https://download.shorebird.dev/'; const kCocoaPods = 'https://cocoapods.org/'; const kGitHub = 'https://github.com/'; const kMaven = 'https://maven.google.com/'; diff --git a/packages/flutter_tools/lib/src/ios/application_package.dart b/packages/flutter_tools/lib/src/ios/application_package.dart index f1c8f44849548..0bc7bb5a45851 100644 --- a/packages/flutter_tools/lib/src/ios/application_package.dart +++ b/packages/flutter_tools/lib/src/ios/application_package.dart @@ -130,6 +130,17 @@ class BuildableIOSApp extends IOSApp { @override String? get name => _appProductName; + String get shorebirdYamlPath => globals.fs.path.join( + archiveBundleOutputPath, + 'Products', + 'Applications', + _appProductName != null ? '$_appProductName.app' : 'Runner.app', + 'Frameworks', + 'App.framework', + 'flutter_assets', + 'shorebird.yaml', + ); + @override String get simulatorBundlePath => _buildAppPath(XcodeSdk.IPhoneSimulator.platformName); diff --git a/packages/flutter_tools/lib/src/ios/core_devices.dart b/packages/flutter_tools/lib/src/ios/core_devices.dart index db8565bd98eab..47024d392e9ca 100644 --- a/packages/flutter_tools/lib/src/ios/core_devices.dart +++ b/packages/flutter_tools/lib/src/ios/core_devices.dart @@ -14,6 +14,7 @@ import '../base/logger.dart'; import '../base/process.dart'; import '../base/template.dart'; import '../base/utils.dart'; +import '../build_info.dart'; import '../convert.dart'; import '../device.dart'; import '../macos/xcode.dart'; @@ -96,6 +97,7 @@ class IOSCoreDeviceLauncher { required String bundleId, required List launchArguments, required ShutdownHooks shutdownHooks, + required BuildMode mode, }) async { // Install app to device final (bool installStatus, IOSCoreDeviceInstallResult? installResult) = await _coreDeviceControl @@ -139,6 +141,7 @@ class IOSCoreDeviceLauncher { deviceId: deviceId, appProcessId: processId, lldbLogForwarder: lldbLogForwarder, + mode: mode, ); // If it fails to attach with lldb, kill the launched process so it doesn't stay hanging. diff --git a/packages/flutter_tools/lib/src/ios/devices.dart b/packages/flutter_tools/lib/src/ios/devices.dart index 0b0b1345b55f3..2ad6bdbd16e54 100644 --- a/packages/flutter_tools/lib/src/ios/devices.dart +++ b/packages/flutter_tools/lib/src/ios/devices.dart @@ -530,6 +530,7 @@ class IOSDevice extends Device { logger: globals.logger, platform: FlutterDarwinPlatform.ios, project: package.project.parent, + device: this, ); _logger.printError(''); return LaunchResult.failed(); @@ -1056,6 +1057,7 @@ class IOSDevice extends Device { bundleId: package.id, launchArguments: launchArguments, shutdownHooks: globals.shutdownHooks, + mode: debuggingOptions.buildInfo.mode, ); // If it succeeds to launch with LLDB, return, otherwise continue on to diff --git a/packages/flutter_tools/lib/src/ios/lldb.dart b/packages/flutter_tools/lib/src/ios/lldb.dart index 35d0df37551db..5812c0366afe1 100644 --- a/packages/flutter_tools/lib/src/ios/lldb.dart +++ b/packages/flutter_tools/lib/src/ios/lldb.dart @@ -11,6 +11,7 @@ import '../base/io.dart'; import '../base/logger.dart'; import '../base/process.dart'; import '../base/utils.dart'; +import '../build_info.dart'; /// LLDB is the default debugger in Xcode on macOS. Once the application has /// launched on a physical iOS device, you can attach to it using LLDB. @@ -47,6 +48,11 @@ class LLDB { /// Example: (lldb) Process 6152 resuming static final _lldbProcessResuming = RegExp(r'Process \d+ resuming'); + /// Pattern of lldb log when the process has started and the breakpoint is added. + /// + /// Example: (lldb) 1 location added to breakpoint 1 + static final _lldbBreakpointAdded = RegExp(r'location added to breakpoint'); + /// Pattern of lldb log when the breakpoint is added. /// /// Example: Breakpoint 1: no locations (pending). @@ -88,6 +94,7 @@ return False required String deviceId, required int appProcessId, required LLDBLogForwarder lldbLogForwarder, + required BuildMode mode, }) async { Timer? timer; try { @@ -111,9 +118,11 @@ return False return false; } await _selectDevice(deviceId); - await _setBreakpoint(); + if (mode == BuildMode.debug) { + await _setBreakpoint(); + } await _attachToAppProcess(appProcessId); - await _resumeProcess(); + await _resumeProcess(mode); _isAttached = true; } on _LLDBError catch (e) { _logger.printTrace('lldb failed with error: ${e.message}'); @@ -146,7 +155,6 @@ return False appProcessId: appProcessId, logger: _logger, ); - final StreamSubscription stdoutSubscription = _lldbProcess!.stdout .transform(utf8LineDecoder) .listen((String line) { @@ -240,12 +248,20 @@ return False await _lldbProcess?.stdinWriteln('breakpoint command add --script-type python $breakpointId'); await _lldbProcess?.stdinWriteln(_pythonScript); await _lldbProcess?.stdinWriteln('DONE'); + + // Disable asynchronous mode to workaround issues with rearming of breakpoints. + // See https://github.com/flutter/flutter/issues/184254 and upstream issue + // https://github.com/llvm/llvm-project/issues/190956. + await _lldbProcess?.stdinWriteln('script lldb.debugger.SetAsync(False)'); } /// Resume the stopped process. - Future _resumeProcess() async { + Future _resumeProcess(BuildMode mode) async { final Future futureLog = _startWaitingForLog( - _lldbProcessResuming, + // When using debug mode, a breakpoint is added once the process resumes and no resume log + // is shown. Instead we match on the breakpoint added log. In profile mode, a resume log is + // shown once the process resumes and no breakpoint log is shown. + mode == BuildMode.debug ? _lldbBreakpointAdded : _lldbProcessResuming, ).then((value) => value, onError: _handleAsyncError); await _lldbProcess?.stdinWriteln('process continue'); diff --git a/packages/flutter_tools/lib/src/ios/mac.dart b/packages/flutter_tools/lib/src/ios/mac.dart index 0339236f53f6b..79d2f15a898c5 100644 --- a/packages/flutter_tools/lib/src/ios/mac.dart +++ b/packages/flutter_tools/lib/src/ios/mac.dart @@ -37,6 +37,7 @@ import '../migrations/xcode_script_build_phase_migration.dart'; import '../migrations/xcode_thin_binary_build_phase_input_paths_migration.dart'; import '../plugins.dart'; import '../project.dart'; +import '../shorebird/ios_build_trace_session.dart'; import 'application_package.dart'; import 'code_signing.dart'; import 'migrations/host_app_info_plist_migration.dart'; @@ -336,6 +337,7 @@ Future buildXcodeProject({ project: project, targetOverride: targetOverride, buildInfo: buildInfo, + printWarnings: true, ); if (app.project.usesSwiftPackageManager) { final String? iosDeploymentTarget = buildSettings['IPHONEOS_DEPLOYMENT_TARGET']; @@ -347,11 +349,24 @@ Future buildXcodeProject({ ); } } + // Created early enough to capture pod install, which can be minutes + // on plugin-heavy apps โ€” otherwise it's an invisible gap between + // "flutter build ios" starting and the xcode archive span. + final IosBuildTraceSession? traceSession = IosBuildTraceSession.maybeStart( + shorebirdTraceFilePath: buildInfo.shorebirdTraceFilePath, + fileSystem: globals.fs, + buildDirectoryPath: globals.fs.path.join(getBuildDirectory(), 'ios'), + ); + + traceSession?.onBeforePodInstall(); await processPodsIfNeeded(project.ios, buildDirectoryPath, buildInfo.mode); + traceSession?.onAfterPodInstall(); if (configOnly) { return XcodeBuildResult(success: true); } + buildCommands.addAll(traceSession?.extraBuildCommands() ?? const []); + if (globals.logger.isVerbose) { // An environment variable to be passed to xcode_backend.sh determining // whether to echo back executed commands. @@ -530,6 +545,8 @@ Future buildXcodeProject({ ]); } + traceSession?.onXcodeAboutToStart(); + final sw = Stopwatch()..start(); initialBuildStatus = globals.logger.startProgress('Running Xcode build...'); @@ -554,6 +571,13 @@ Future buildXcodeProject({ ), ); + await traceSession?.onXcodeFinished( + buildActionName: xcodeBuildActionToString(buildAction), + resultBundleDirectory: resultBundleDirectory, + runXcresultTool: (List args) => globals.processManager.run(args), + ); + traceSession?.finish(printStatus: globals.printStatus); + if (tempDir.existsSync()) { // Display additional warning and error message from xcresult bundle. final Directory resultBundle = tempDir.childDirectory(_kResultBundlePath); @@ -783,6 +807,7 @@ Future diagnoseXcodeBuildFailure( required FileSystem fileSystem, required FlutterDarwinPlatform platform, required FlutterProject project, + Device? device, }) async { final XcodeBuildExecution? xcodeBuildExecution = result.xcodeBuildExecution; if (xcodeBuildExecution != null && @@ -811,6 +836,7 @@ Future diagnoseXcodeBuildFailure( platform: platform, logger: logger, fileSystem: fileSystem, + device: device, ); if (!issueDetected && xcodeBuildExecution != null) { @@ -1000,6 +1026,19 @@ _XCResultIssueHandlingResult _handleXCResultIssue({ hasProvisioningProfileIssue: false, modifiedPrecompiledSource: true, ); + } else if (message.contains( + 'Unable to find a destination matching the provided destination specifier', + ) && + message.contains('platform:iOS Simulator, arch:x86_64,') && + !message.contains('platform:iOS Simulator, arch:arm64,') && + !message.contains( + 'The requested device could not be found because no available devices matched the request.', + )) { + return _XCResultIssueHandlingResult( + requiresProvisioningProfile: false, + hasProvisioningProfileIssue: false, + unableToFindArmDestination: true, + ); } return _XCResultIssueHandlingResult( requiresProvisioningProfile: false, @@ -1015,11 +1054,13 @@ Future _handleIssues( required FlutterDarwinPlatform platform, required Logger logger, required FileSystem fileSystem, + Device? device, }) async { var requiresProvisioningProfile = false; var hasProvisioningProfileIssue = false; var issueDetected = false; var modifiedPrecompiledSource = false; + var unableToFindArmDestination = false; String? missingPlatform; final duplicateModules = []; final missingModules = []; @@ -1046,6 +1087,7 @@ Future _handleIssues( missingModules.add(handlingResult.missingModule!); } modifiedPrecompiledSource = handlingResult.modifiedPrecompiledSource; + unableToFindArmDestination = handlingResult.unableToFindArmDestination; issueDetected = true; } } else if (xcResult != null) { @@ -1123,10 +1165,42 @@ Future _handleIssues( 'the cache.\n' 'โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•', ); + } else if (unableToFindArmDestination && + xcodeBuildExecution != null && + xcodeBuildExecution.environmentType == EnvironmentType.simulator && + device != null) { + final bool simulatorSupportsIntel = await _simulatorSupportsIntel(device); + if (!simulatorSupportsIntel) { + logger.printError( + 'โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•\n' + 'The selected simulator is incompatible with the current build settings.\n' + 'Please use a simulator that supports x86_64, such as a simulator prior to iOS 26 or ' + 'download the universal variant of the iOS 26 simulator using ' + '"xcodebuild -downloadPlatform iOS -architectureVariant universal".\n' + 'โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•', + ); + } } return issueDetected; } +Future _simulatorSupportsIntel(Device device) async { + final Version? xcodeVersion = globals.xcode?.currentVersion; + if (xcodeVersion != null && xcodeVersion.major < 26) { + return true; + } + final String runtime = await device.sdkNameAndVersion; + final RunResult result = await globals.processUtils.run([ + ...globals.xcode!.xcrunCommand(), + 'simctl', + 'list', + 'runtimes', + runtime, + '--json', + ]); + return result.stdout.contains('x86_64'); +} + /// Returns true if a Package.swift is found for the plugin and a podspec is not. Future _isPluginSwiftPackageOnly({ required FlutterDarwinPlatform platform, @@ -1261,6 +1335,7 @@ class _XCResultIssueHandlingResult { this.duplicateModule, this.missingModule, this.modifiedPrecompiledSource = false, + this.unableToFindArmDestination = false, }); /// An issue indicates that user didn't provide the provisioning profile. @@ -1282,6 +1357,8 @@ class _XCResultIssueHandlingResult { /// An issue indicates that a source file, such as a header in the Flutter framework, has /// changed since last built. This requires "flutter clean" to resolve. final bool modifiedPrecompiledSource; + + final bool unableToFindArmDestination; } const _kResultBundlePath = 'temporary_xcresult_bundle'; diff --git a/packages/flutter_tools/lib/src/ios/simulators.dart b/packages/flutter_tools/lib/src/ios/simulators.dart index a3deb00828a43..81d3638a5e8ce 100644 --- a/packages/flutter_tools/lib/src/ios/simulators.dart +++ b/packages/flutter_tools/lib/src/ios/simulators.dart @@ -558,6 +558,7 @@ class IOSSimulator extends Device { logger: globals.logger, platform: FlutterDarwinPlatform.ios, project: app.project.parent, + device: this, ); throwToolExit('Could not build the application for the simulator.'); } diff --git a/packages/flutter_tools/lib/src/ios/xcode_build_settings.dart b/packages/flutter_tools/lib/src/ios/xcode_build_settings.dart index be4ddb36312fc..f629c4a1fb721 100644 --- a/packages/flutter_tools/lib/src/ios/xcode_build_settings.dart +++ b/packages/flutter_tools/lib/src/ios/xcode_build_settings.dart @@ -5,7 +5,6 @@ import '../artifacts.dart'; import '../base/common.dart'; import '../base/file_system.dart'; -import '../base/version.dart'; import '../build_info.dart'; import '../cache.dart'; import '../flutter_manifest.dart'; @@ -36,6 +35,7 @@ Future updateGeneratedXcodeProperties({ bool useMacOSConfig = false, String? buildDirOverride, String? configurationBuildDir, + bool printWarnings = false, }) async { final List xcodeBuildSettings = await _xcodeBuildSettingsLines( project: project, @@ -44,6 +44,7 @@ Future updateGeneratedXcodeProperties({ useMacOSConfig: useMacOSConfig, buildDirOverride: buildDirOverride, configurationBuildDir: configurationBuildDir, + printWarnings: printWarnings, ); _updateGeneratedXcodePropertiesFile( @@ -150,6 +151,7 @@ Future> _xcodeBuildSettingsLines({ bool useMacOSConfig = false, String? buildDirOverride, String? configurationBuildDir, + required bool printWarnings, }) async { final xcodeBuildSettings = []; @@ -233,11 +235,10 @@ Future> _xcodeBuildSettingsLines({ // If any plugins or their dependencies do not support arm64 simulators // (to run natively without Rosetta translation on an ARM Mac), // the app will fail to build unless it also excludes arm64 simulators. - final Version? xcodeVersion = globals.xcode?.currentVersion; - if (xcodeVersion != null && xcodeVersion.major >= 26) { - await project.ios.checkForPluginsExcludingArmSimulator(); + var excludedSimulatorArchs = 'i386'; + if (!(await project.ios.pluginsSupportArmSimulator(printWarnings: printWarnings))) { + excludedSimulatorArchs += ' arm64'; } - const excludedSimulatorArchs = 'i386'; xcodeBuildSettings.add( 'EXCLUDED_ARCHS[sdk=${XcodeSdk.IPhoneSimulator.platformName}*]=$excludedSimulatorArchs', ); diff --git a/packages/flutter_tools/lib/src/isolated/resident_web_runner.dart b/packages/flutter_tools/lib/src/isolated/resident_web_runner.dart index 74a879995caab..39ac4e4f35668 100644 --- a/packages/flutter_tools/lib/src/isolated/resident_web_runner.dart +++ b/packages/flutter_tools/lib/src/isolated/resident_web_runner.dart @@ -4,6 +4,7 @@ import 'dart:async'; +import 'package:dds/dds.dart'; import 'package:dwds/dwds.dart'; import 'package:package_config/package_config.dart'; import 'package:unified_analytics/unified_analytics.dart'; @@ -377,24 +378,32 @@ class ResidentWebRunner extends ResidentRunner { }); } on WebSocketException catch (error, stackTrace) { appFailedToStart(); - _logger.printError('$error', stackTrace: stackTrace); + _logger.printError(error.toString(), stackTrace: stackTrace); throwToolExit(kExitMessage); } on ChromeDebugException catch (error, stackTrace) { appFailedToStart(); - _logger.printError('$error', stackTrace: stackTrace); + _logger.printError(error.toString(), stackTrace: stackTrace); throwToolExit(kExitMessage); } on AppConnectionException catch (error, stackTrace) { appFailedToStart(); - _logger.printError('$error', stackTrace: stackTrace); + _logger.printError(error.toString(), stackTrace: stackTrace); throwToolExit(kExitMessage); } on SocketException catch (error, stackTrace) { appFailedToStart(); - _logger.printError('$error', stackTrace: stackTrace); + _logger.printError(error.toString(), stackTrace: stackTrace); throwToolExit(kExitMessage); } on HttpException catch (error, stackTrace) { appFailedToStart(); - _logger.printError('$error', stackTrace: stackTrace); + _logger.printError(error.toString(), stackTrace: stackTrace); throwToolExit(kExitMessage); + } on DartDevelopmentServiceException catch (error) { + // The application may have started shutting down before DDS was able to finish establishing + // its connection to DWDS. Don't treat this as an unhandled exception. + appFailedToStart(); + if (error.errorCode == DartDevelopmentServiceException.failedToStartError) { + throwToolExit(kExitMessage); + } + rethrow; } on Exception { appFailedToStart(); rethrow; diff --git a/packages/flutter_tools/lib/src/isolated/web_asset_server.dart b/packages/flutter_tools/lib/src/isolated/web_asset_server.dart index 6f987c1ba4167..639501bc23ca3 100644 --- a/packages/flutter_tools/lib/src/isolated/web_asset_server.dart +++ b/packages/flutter_tools/lib/src/isolated/web_asset_server.dart @@ -79,6 +79,7 @@ class WebAssetServer implements AssetReader { required this.webRenderer, required this.useLocalCanvasKit, required this.fileSystem, + required this.logger, Map webDefines = const {}, }) : basePath = WebTemplate.baseHref(htmlTemplate(fileSystem, 'index.html', _kDefaultIndex)), _webDefines = webDefines { @@ -266,6 +267,7 @@ class WebAssetServer implements AssetReader { webRenderer: webRenderer, useLocalCanvasKit: useLocalCanvasKit, fileSystem: fileSystem, + logger: logger, webDefines: webDefines, ); final int selectedPort = server.selectedPort; @@ -595,6 +597,7 @@ class WebAssetServer implements AssetReader { final bool useLocalCanvasKit; final FileSystem fileSystem; + final Logger logger; String get _buildConfigString { final buildConfig = { @@ -634,6 +637,7 @@ _flutter.buildConfig = ${jsonEncode(buildConfig)}; serviceWorkerVersion: null, buildConfig: _buildConfigString, flutterJsFile: _flutterJsFile, + logger: logger, webDefines: _webDefines, ); } @@ -657,6 +661,7 @@ _flutter.buildConfig = ${jsonEncode(buildConfig)}; buildConfig: _buildConfigString, flutterJsFile: _flutterJsFile, flutterBootstrapJs: _flutterBootstrapJsContent, + logger: logger, webDefines: _webDefines, ), encoding: utf8, diff --git a/packages/flutter_tools/lib/src/macos/cocoapods.dart b/packages/flutter_tools/lib/src/macos/cocoapods.dart index 00614acd8422e..f27d517d14251 100644 --- a/packages/flutter_tools/lib/src/macos/cocoapods.dart +++ b/packages/flutter_tools/lib/src/macos/cocoapods.dart @@ -2,8 +2,11 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'dart:convert'; + import 'package:file/file.dart'; import 'package:process/process.dart'; +import 'package:shorebird_build_trace/shorebird_build_trace.dart'; import 'package:unified_analytics/unified_analytics.dart'; import '../base/common.dart'; @@ -336,16 +339,28 @@ class CocoaPods { Future _runPodInstall(XcodeBasedProject xcodeProject, BuildMode buildMode) async { final Status status = _logger.startProgress('Running pod install...'); - final ProcessResult result = await _processManager.run( - ['pod', 'install', '--verbose'], - workingDirectory: _fileSystem.path.dirname(xcodeProject.podfile.path), - environment: { - // See https://github.com/flutter/flutter/issues/10873. - // CocoaPods analytics adds a lot of latency. - 'COCOAPODS_DISABLE_STATS': 'true', - 'LANG': 'en_US.UTF-8', - }, - ); + + // Shorebird-specific: when a build trace is active, stream pod install + // output so we can timestamp phase transitions. On plugin-heavy apps + // pod install can be minutes, and a single opaque span hides where the + // time goes (dependency analysis vs downloads vs project generation vs + // integration). + final BuildTracer? tracer = BuildTracer.current; + final ProcessResult result; + if (tracer == null) { + result = await _processManager.run( + ['pod', 'install', '--verbose'], + workingDirectory: _fileSystem.path.dirname(xcodeProject.podfile.path), + environment: { + // See https://github.com/flutter/flutter/issues/10873. + // CocoaPods analytics adds a lot of latency. + 'COCOAPODS_DISABLE_STATS': 'true', + 'LANG': 'en_US.UTF-8', + }, + ); + } else { + result = await _runPodInstallStreamed(tracer, xcodeProject); + } status.stop(); if (_logger.isVerbose || result.exitCode != 0) { final stdout = result.stdout as String; @@ -374,6 +389,60 @@ class CocoaPods { } } + /// Runs `pod install --verbose` with live stdout streaming so phase + /// transitions can be timestamped into [tracer]. Returns a [ProcessResult] + /// shaped the same as [ProcessManager.run] would have returned, so + /// downstream error handling keeps working unchanged. + Future _runPodInstallStreamed( + BuildTracer tracer, + XcodeBasedProject xcodeProject, + ) async { + final Process process = await _processManager.start( + ['pod', 'install', '--verbose'], + workingDirectory: _fileSystem.path.dirname(xcodeProject.podfile.path), + environment: {'COCOAPODS_DISABLE_STATS': 'true', 'LANG': 'en_US.UTF-8'}, + ); + // Real pid of the `pod` process. Phase spans land on this pid + + // tid=1, with a `process_name` metadata event so Perfetto groups + // pod install work into its own row. + final int podPid = process.pid; + tracer + ..addProcessNameMetadata(pid: podPid, name: TraceNames.podInstallSpanName) + ..addThreadNameMetadata(pid: podPid, tid: 1, name: TraceNames.podInstallSpanName); + final phases = PhaseTracker( + tracer: tracer, + pid: podPid, + tid: 1, + namePrefix: TraceNames.podInstallNamePrefix, + ); + + final stdoutBuf = StringBuffer(); + final stderrBuf = StringBuffer(); + + final Future stdoutDone = process.stdout + .transform(utf8.decoder) + .transform(const LineSplitter()) + .listen((String line) { + stdoutBuf + ..write(line) + ..write('\n'); + final PodInstallPhase? phase = _podPhaseForLogLine(line); + if (phase != null) phases.transitionTo(phase.wireName); + }) + .asFuture(); + + final Future stderrDone = process.stderr + .transform(utf8.decoder) + .listen(stderrBuf.write) + .asFuture(); + + final int exitCode = await process.exitCode; + await Future.wait(>[stdoutDone, stderrDone]); + phases.end(); + + return ProcessResult(process.pid, exitCode, stdoutBuf.toString(), stderrBuf.toString()); + } + void _diagnosePodInstallFailure(ProcessResult result, XcodeBasedProject xcodeProject) { final Object? stdout = result.stdout; final Object? stderr = result.stderr; @@ -580,3 +649,27 @@ class CocoaPods { } } } + +/// CocoaPods verbose-mode log markers, in the order they print during a +/// normal `pod install`. A match means "from now on, the process is in +/// this phase" โ€” the `PhaseTracker` closes the prior phase and opens a +/// new one. +/// +/// These substrings have been stable across recent CocoaPods releases; +/// if CocoaPods renames one, the worst case is we miss a phase in the +/// trace โ€” pod install still succeeds. +const _podPhaseMarkers = { + 'Analyzing dependencies': PodInstallPhase.analyzing, + 'Downloading dependencies': PodInstallPhase.downloading, + 'Generating Pods project': PodInstallPhase.generating, + 'Integrating client project': PodInstallPhase.integrating, +}; + +/// Returns the phase a `pod install --verbose` log [line] transitions +/// into, or null if the line doesn't contain any of the known markers. +PodInstallPhase? _podPhaseForLogLine(String line) { + for (final MapEntry entry in _podPhaseMarkers.entries) { + if (line.contains(entry.key)) return entry.value; + } + return null; +} diff --git a/packages/flutter_tools/lib/src/macos/swift_package_manager.dart b/packages/flutter_tools/lib/src/macos/swift_package_manager.dart index 562603a741f6b..18f7fb37ddf5a 100644 --- a/packages/flutter_tools/lib/src/macos/swift_package_manager.dart +++ b/packages/flutter_tools/lib/src/macos/swift_package_manager.dart @@ -15,6 +15,10 @@ import 'swift_packages.dart'; /// dependencies on Flutter plugin swift packages. const kFlutterGeneratedPluginSwiftPackageName = 'FlutterGeneratedPluginSwiftPackage'; +/// The name of the Swift pacakge that's generated by the Flutter tool to add +/// a dependency on the Flutter/FlutterMacOS framework. +const kFlutterGeneratedFrameworkSwiftPackageTargetName = 'FlutterFramework'; + /// Swift Package Manager is a dependency management solution for iOS and macOS /// applications. /// @@ -87,6 +91,24 @@ class SwiftPackageManager { templateRenderer: _templateRenderer, ); pluginsPackage.createSwiftPackage(); + + final flutterFrameworkPackage = SwiftPackage( + manifest: project.flutterFrameworkSwiftPackageDirectory.childFile('Package.swift'), + name: kFlutterGeneratedFrameworkSwiftPackageTargetName, + platforms: [platform.supportedPackagePlatform], + products: [ + SwiftPackageProduct( + name: kFlutterGeneratedFrameworkSwiftPackageTargetName, + targets: [kFlutterGeneratedFrameworkSwiftPackageTargetName], + ), + ], + dependencies: [], + targets: [ + SwiftPackageTarget.defaultTarget(name: kFlutterGeneratedFrameworkSwiftPackageTargetName), + ], + templateRenderer: _templateRenderer, + ); + flutterFrameworkPackage.createSwiftPackage(); } (List, List) diff --git a/packages/flutter_tools/lib/src/platform_plugins.dart b/packages/flutter_tools/lib/src/platform_plugins.dart index 0dac5673cb6a5..7f2f54ed2c42d 100644 --- a/packages/flutter_tools/lib/src/platform_plugins.dart +++ b/packages/flutter_tools/lib/src/platform_plugins.dart @@ -439,7 +439,12 @@ class WindowsPlugin extends PluginPlatform implements NativeOrDartPlugin, Varian this.defaultPackage, this.variants = const {}, }) : ffiPlugin = ffiPlugin ?? false, - assert(pluginClass != null || dartPluginClass != null || defaultPackage != null); + assert( + pluginClass != null || + dartPluginClass != null || + defaultPackage != null || + (ffiPlugin ?? false), + ); factory WindowsPlugin.fromYaml(String name, YamlMap yaml) { assert(validate(yaml)); diff --git a/packages/flutter_tools/lib/src/run_hot.dart b/packages/flutter_tools/lib/src/run_hot.dart index d7c46a9285a49..aa933b42edfe0 100644 --- a/packages/flutter_tools/lib/src/run_hot.dart +++ b/packages/flutter_tools/lib/src/run_hot.dart @@ -128,12 +128,11 @@ class HotRunner extends ResidentRunner { final benchmarkData = >{}; + @visibleForTesting + String? get targetPlatformName => _targetPlatformName; String? _targetPlatformName; - TargetPlatform get _targetPlatform => _targetPlatformName != null - ? getTargetPlatformForName(_targetPlatformName!) - : throw ArgumentError( - 'Access to the target platform needs a call to _calculateTargetPlatform first', - ); + final _targetPlatforms = {}; + String? _sdkName; bool? _emulator; @@ -150,15 +149,22 @@ class HotRunner extends ResidentRunner { if (_targetPlatformName != null) { return; } - + assert(_targetPlatforms.isEmpty); switch (flutterDevices.length) { case 1: final Device device = flutterDevices.first.device!; - _targetPlatformName = getNameForTargetPlatform(await device.targetPlatform); + final TargetPlatform targetPlatform = await device.targetPlatform; + _targetPlatformName = getNameForTargetPlatform(targetPlatform); + _targetPlatforms.add(targetPlatform); _sdkName = await device.sdkNameAndVersion; _emulator = await device.isLocalEmulator; case > 1: _targetPlatformName = 'multiple'; + _targetPlatforms.addAll( + await Future.wait([ + for (final flutterDevice in flutterDevices) flutterDevice.device!.targetPlatform, + ]), + ); _sdkName = 'multiple'; _emulator = false; default: @@ -302,7 +308,7 @@ class HotRunner extends ResidentRunner { final initialUpdateDevFSsTimer = Stopwatch()..start(); final UpdateFSReport devfsResult = await _updateDevFS( fullRestart: needsFullRestart, - targetPlatform: _targetPlatform, + targetPlatforms: _targetPlatforms, ); _addBenchmarkData( @@ -485,24 +491,26 @@ class HotRunner extends ResidentRunner { Future _updateDevFS({ bool fullRestart = false, - required TargetPlatform targetPlatform, + required Set targetPlatforms, }) async { final bool isFirstUpload = !assetBundle.wasBuiltOnce(); final bool rebuildBundle = assetBundle.needsBuild(); if (rebuildBundle) { - globals.printTrace('Updating assets'); - final int result = await assetBundle.build( - flutterHookResult: await dartBuilder?.runHooks( + for (final targetPlatform in targetPlatforms) { + globals.printTrace('Updating assets for ${targetPlatform.osName}'); + final int result = await assetBundle.build( + flutterHookResult: await dartBuilder?.runHooks( + targetPlatform: targetPlatform, + environment: environment, + logger: _logger, + ), + packageConfigPath: debuggingOptions.buildInfo.packageConfigPath, + flavor: debuggingOptions.buildInfo.flavor, targetPlatform: targetPlatform, - environment: environment, - logger: _logger, - ), - packageConfigPath: debuggingOptions.buildInfo.packageConfigPath, - flavor: debuggingOptions.buildInfo.flavor, - targetPlatform: targetPlatform, - ); - if (result != 0) { - return UpdateFSReport(); + ); + if (result != 0) { + return UpdateFSReport(); + } } } @@ -612,7 +620,7 @@ class HotRunner extends ResidentRunner { final restartTimer = Stopwatch()..start(); UpdateFSReport updatedDevFS; try { - updatedDevFS = await _updateDevFS(fullRestart: true, targetPlatform: _targetPlatform); + updatedDevFS = await _updateDevFS(fullRestart: true, targetPlatforms: _targetPlatforms); } finally { hotRunnerConfig!.updateDevFSComplete(); } @@ -1027,7 +1035,7 @@ class HotRunner extends ResidentRunner { final devFSTimer = Stopwatch()..start(); UpdateFSReport updatedDevFS; try { - updatedDevFS = await _updateDevFS(targetPlatform: _targetPlatform); + updatedDevFS = await _updateDevFS(targetPlatforms: _targetPlatforms); } finally { hotRunnerConfig!.updateDevFSComplete(); } diff --git a/packages/flutter_tools/lib/src/runner/flutter_command.dart b/packages/flutter_tools/lib/src/runner/flutter_command.dart index b0afa20378c94..bfffcbbed3bcb 100644 --- a/packages/flutter_tools/lib/src/runner/flutter_command.dart +++ b/packages/flutter_tools/lib/src/runner/flutter_command.dart @@ -131,6 +131,10 @@ abstract final class FlutterOptions { static const kDartDefineFromFileOption = 'dart-define-from-file'; static const kWebDefinesOption = 'web-define'; static const kPerformanceMeasurementFile = 'performance-measurement-file'; + // Shorebird-specific build-trace option. Prefixed so the flag name and + // generated env vars / Gradle properties don't squat on identifiers + // upstream Flutter might want later. + static const kShorebirdTrace = 'shorebird-trace'; static const kDeviceUser = 'device-user'; static const kDeviceTimeout = 'device-timeout'; static const kDeviceConnection = 'device-connection'; @@ -772,7 +776,7 @@ abstract class FlutterCommand extends Command { 'Variables are replaced in the format {{VARIABLE_NAME}}.\n' 'Multiple defines can be passed by repeating "--${FlutterOptions.kWebDefinesOption}" multiple times.\n' 'If a template contains a variable placeholder but no corresponding "--web-define" is provided, ' - 'the build will fail with an error.', + 'it will warn that you have an unhandled variable.', valueHelp: 'API_URL=https://api.example.com', splitCommas: false, ); @@ -1048,6 +1052,19 @@ abstract class FlutterCommand extends Command { ); } + void usesShorebirdTraceOption({bool hide = false}) { + argParser.addOption( + FlutterOptions.kShorebirdTrace, + help: + 'Output a Chrome Trace Event Format JSON file for build ' + 'profiling. The resulting file can be viewed at ' + 'https://ui.perfetto.dev. Shorebird-specific; named to avoid ' + 'colliding with any future upstream trace option.', + hide: hide, + valueHelp: 'path/to/shorebird-trace.json', + ); + } + void addAndroidSpecificBuildOptions({bool hide = false}) { argParser.addFlag( FlutterOptions.kAndroidGradleDaemon, @@ -1426,6 +1443,11 @@ abstract class FlutterCommand extends Command { ? stringArg(FlutterOptions.kPerformanceMeasurementFile) : null; + final String? shorebirdTraceFilePath = + argParser.options.containsKey(FlutterOptions.kShorebirdTrace) + ? stringArg(FlutterOptions.kShorebirdTrace) + : null; + final Map defineConfigJsonMap = extractDartDefineConfigJsonMap(); final List dartDefines = extractDartDefines(defineConfigJsonMap: defineConfigJsonMap); @@ -1477,6 +1499,7 @@ abstract class FlutterCommand extends Command { dartDefines: dartDefines, dartExperiments: experiments, performanceMeasurementFile: performanceMeasurementFile, + shorebirdTraceFilePath: shorebirdTraceFilePath, packageConfigPath: packagesPath ?? packageConfigFile.path, codeSizeDirectory: codeSizeDirectory, androidGradleDaemon: androidGradleDaemon, diff --git a/packages/flutter_tools/lib/src/shorebird/android_build_trace_session.dart b/packages/flutter_tools/lib/src/shorebird/android_build_trace_session.dart new file mode 100644 index 0000000000000..5b01afa30fbc5 --- /dev/null +++ b/packages/flutter_tools/lib/src/shorebird/android_build_trace_session.dart @@ -0,0 +1,210 @@ +// Shorebird-specific. Keeps the build-trace plumbing for +// `flutter build apk` / `flutter build appbundle` out of gradle.dart +// so the Shorebird fork's diff against upstream stays small and the +// build flow reads the same as upstream. + +import 'dart:io' show Process; + +import 'package:shorebird_build_trace/shorebird_build_trace.dart'; + +import '../base/file_system.dart'; +import '../build_info.dart'; +import '../cache.dart'; +import 'network_trace_span.dart'; + +/// Wraps the lifecycle of a Shorebird build trace across one +/// `buildGradleApp` call. Returned by [maybeStart] only when +/// `--shorebird-trace=` was passed; the constructor installs +/// [BuildTracer.current] and [finish] / [abortOnGradleFailure] +/// clear it, so gradle.dart itself never touches the static. +/// +/// Manual start/stop (rather than a body-wrapping closure) because +/// wrapping the ~200-line `buildGradleApp` body would force a reindent +/// that balloons our diff against upstream flutter and makes every +/// subsequent merge of that method harder. +class AndroidBuildTraceSession { + AndroidBuildTraceSession._({ + required BuildTracer tracer, + required FileSystem fileSystem, + required String tracePath, + required Directory buildDirectory, + }) : _tracer = tracer, + _fs = fileSystem, + _tracePath = tracePath, + _buildDirectory = buildDirectory, + _flutterPid = currentProcessId(), + _buildStart = DateTime.now() { + BuildTracer.start(_tracer); + _tracer + ..addProcessNameMetadata(pid: _flutterPid, name: 'flutter_tool') + ..addThreadNameMetadata(pid: _flutterPid, tid: _flutterToolTid, name: 'flutter tool') + ..addThreadNameMetadata(pid: _flutterPid, tid: _gradleWaitTid, name: 'gradle (wait)') + ..addThreadNameMetadata(pid: _flutterPid, tid: networkTid, name: 'network'); + } + + /// Returns a session when a trace path is configured for this build, + /// null otherwise. + static AndroidBuildTraceSession? maybeStart( + AndroidBuildInfo androidBuildInfo, + FileSystem fileSystem, + Directory buildDirectory, + ) { + final String? path = androidBuildInfo.buildInfo.shorebirdTraceFilePath; + if (path == null) { + return null; + } + return AndroidBuildTraceSession._( + tracer: BuildTracer(), + fileSystem: fileSystem, + tracePath: path, + buildDirectory: buildDirectory, + ); + } + + final BuildTracer _tracer; + final FileSystem _fs; + final String _tracePath; + final Directory _buildDirectory; + final int _flutterPid; + final DateTime _buildStart; + + static const int _flutterToolTid = 1; + static const int _gradleWaitTid = 2; + + String? _assembleTraceFilePath; + String? _gradleTaskTraceFilePath; + DateTime? _gradleStart; + DateTime? _gradleEnd; + + /// Returns the `-P`/`-I=` flags to thread trace plumbing into Gradle. + /// Safe to splat into the options list unconditionally. + /// + /// [assembleTask] carries the variant + build type (e.g. + /// `assembleFooRelease` / `bundleRelease`); the intermediate trace + /// paths embed it so a single Gradle invocation running multiple + /// variants in parallel can't have per-variant FlutterTask instances + /// stomp on each other's trace. + List extraGradleOptions(String assembleTask) { + final String assembleTrace = _fs.path.join( + _buildDirectory.path, + 'intermediates', + 'shorebird', + 'shorebird_assemble_trace_$assembleTask.json', + ); + final String gradleTaskTrace = _fs.path.join( + _buildDirectory.path, + 'intermediates', + 'shorebird', + 'shorebird_gradle_task_trace_$assembleTask.json', + ); + final String initScript = _fs.path.join( + Cache.flutterRoot!, + 'packages', + 'flutter_tools', + 'gradle', + 'shorebird_trace_init.gradle', + ); + _assembleTraceFilePath = assembleTrace; + _gradleTaskTraceFilePath = gradleTaskTrace; + return [ + '-Pshorebird-trace-file=$assembleTrace', + '-I=$initScript', + '-Pshorebird.gradle-trace-file=$gradleTaskTrace', + ]; + } + + /// Hook for `_runGradleTask`'s `preRunTask` โ€” records when the Gradle + /// wrapper is about to run so [finish] can compute the outer build + /// span. + void onGradleAboutToStart() { + final preGradleEnd = DateTime.now(); + _tracer.addCompleteEvent( + name: 'pre-gradle setup', + cat: TraceCategory.flutter.wireName, + pid: _flutterPid, + tid: _flutterToolTid, + start: _buildStart, + end: preGradleEnd, + ); + _gradleStart = DateTime.now(); + } + + /// Hook for `_runGradleTask`'s `onStart` โ€” emits a flow-start tied to + /// Gradle's real pid so Perfetto draws an arrow from our + /// `gradle ` span into the first per-task event the init script + /// emits. + void onGradleSpawn(Process process) { + _tracer.addFlowStart( + id: process.pid, + pid: _flutterPid, + tid: _gradleWaitTid, + at: DateTime.now(), + ); + } + + /// Call after `_runGradleTask` returns, before the exit-code check. + /// Records the outer `gradle ` span and merges the intermediate + /// trace files into the main tracer. + void onGradleFinished(String assembleTask) { + final gradleEnd = DateTime.now(); + _gradleEnd = gradleEnd; + _tracer.addCompleteEvent( + name: '${TraceNames.gradleSpanPrefix}$assembleTask', + cat: TraceCategory.gradle.wireName, + pid: _flutterPid, + tid: _gradleWaitTid, + start: _gradleStart ?? _buildStart, + end: gradleEnd, + ); + final String? assembleTraceFilePath = _assembleTraceFilePath; + if (assembleTraceFilePath != null) { + _tracer.mergeEventsFromFile(_fs.file(assembleTraceFilePath)); + } + final String? gradleTaskTraceFilePath = _gradleTaskTraceFilePath; + if (gradleTaskTraceFilePath != null) { + _tracer.mergeEventsFromFile(_fs.file(gradleTaskTraceFilePath)); + } + } + + /// Call right before `throwToolExit` when Gradle returned non-zero โ€” + /// clears [BuildTracer.current] so the caught ToolExit doesn't leak + /// tracer state into any later code that reads [BuildTracer.current]. + void abortOnGradleFailure() { + BuildTracer.stop(); + } + + /// Writes the merged trace to disk and clears [BuildTracer.current]. + /// Records post-gradle + outer "flutter build" spans first. + /// + /// [buildTarget] is the target suffix (e.g. `apk`, `appbundle`); the + /// outer span name is assembled as + /// `TraceNames.flutterBuildSpanPrefix + buildTarget`. + /// [printStatus] is called once with a user-facing "trace written" + /// message so callers don't have to wire the logger through. + void finish({required String buildTarget, required void Function(String) printStatus}) { + final postGradleEnd = DateTime.now(); + _tracer + ..addCompleteEvent( + name: 'post-gradle processing', + cat: TraceCategory.flutter.wireName, + pid: _flutterPid, + tid: _flutterToolTid, + start: _gradleEnd ?? postGradleEnd, + end: postGradleEnd, + ) + ..addCompleteEvent( + name: '${TraceNames.flutterBuildSpanPrefix}$buildTarget', + cat: TraceCategory.flutter.wireName, + pid: _flutterPid, + tid: _flutterToolTid, + start: _buildStart, + end: postGradleEnd, + ) + ..writeToFile(_fs.file(_tracePath)); + printStatus( + 'Shorebird build trace written to $_tracePath. ' + 'View at https://ui.perfetto.dev', + ); + BuildTracer.stop(); + } +} diff --git a/packages/flutter_tools/lib/src/shorebird/ios_build_trace_session.dart b/packages/flutter_tools/lib/src/shorebird/ios_build_trace_session.dart new file mode 100644 index 0000000000000..d8a7ea3da8e55 --- /dev/null +++ b/packages/flutter_tools/lib/src/shorebird/ios_build_trace_session.dart @@ -0,0 +1,281 @@ +// Shorebird-specific. Keeps the build-trace plumbing for +// `flutter build ios` out of mac.dart so the Shorebird fork's diff +// against upstream stays small and the build flow reads the same as +// upstream. + +import 'dart:async'; +import 'dart:convert'; +import 'dart:io' show ProcessResult; + +import 'package:shorebird_build_trace/shorebird_build_trace.dart'; + +import '../base/file_system.dart'; +import 'network_trace_span.dart'; + +/// Wraps the lifecycle of a Shorebird build trace across one iOS +/// build. Returned by [maybeStart] only when `--shorebird-trace=` +/// was passed; the constructor installs [BuildTracer.current] and +/// [finish] / [abortOnFailure] clear it, so mac.dart itself never +/// touches the static. +/// +/// Manual start/stop (rather than a body-wrapping closure) because +/// wrapping the ~300-line `buildXcodeProject` body would force a +/// reindent that balloons our diff against upstream flutter and makes +/// every subsequent merge of that method harder. +class IosBuildTraceSession { + IosBuildTraceSession._({ + required BuildTracer tracer, + required FileSystem fileSystem, + required String tracePath, + required String buildDirectoryPath, + }) : _tracer = tracer, + _fs = fileSystem, + _tracePath = tracePath, + _buildDirectoryPath = buildDirectoryPath, + _flutterPid = currentProcessId(), + _buildStart = DateTime.now() { + BuildTracer.start(_tracer); + _tracer + ..addProcessNameMetadata(pid: _flutterPid, name: 'flutter_tool') + ..addThreadNameMetadata(pid: _flutterPid, tid: _flutterToolTid, name: 'flutter tool') + ..addThreadNameMetadata(pid: _flutterPid, tid: _xcodeWaitTid, name: 'xcode (wait)') + ..addThreadNameMetadata(pid: _flutterPid, tid: networkTid, name: 'network') + ..addProcessNameMetadata(pid: _xcodeSubsectionPid, name: 'xcodebuild') + ..addThreadNameMetadata(pid: _xcodeSubsectionPid, tid: 1, name: 'xcode phases'); + } + + /// Returns a session when a trace path is configured for this build, + /// null otherwise. [buildDirectoryPath] is the iOS build directory + /// (relative to the project root) where the intermediate assemble + /// trace file is written. + static IosBuildTraceSession? maybeStart({ + required String? shorebirdTraceFilePath, + required FileSystem fileSystem, + required String buildDirectoryPath, + }) { + if (shorebirdTraceFilePath == null) { + return null; + } + return IosBuildTraceSession._( + tracer: BuildTracer(), + fileSystem: fileSystem, + tracePath: shorebirdTraceFilePath, + buildDirectoryPath: buildDirectoryPath, + ); + } + + final BuildTracer _tracer; + final FileSystem _fs; + final String _tracePath; + final String _buildDirectoryPath; + final int _flutterPid; + final DateTime _buildStart; + + static const int _flutterToolTid = 1; + static const int _xcodeWaitTid = 2; + + /// Synthetic pid for Xcode subsection events parsed from xcresult. + /// xcresult doesn't surface xcodebuild's pid, so events end up on + /// this fixed id named via a `process_name` metadata event. + static const int _xcodeSubsectionPid = 0x78636f; // ASCII 'xco' + + DateTime? _podInstallStart; + DateTime? _xcodeStart; + DateTime? _xcodeEnd; + String? _assembleTraceFilePath; + + /// Call immediately before `processPodsIfNeeded` so the resulting + /// span covers its full wall-clock. + void onBeforePodInstall() { + _podInstallStart = DateTime.now(); + } + + /// Call immediately after `processPodsIfNeeded` returns. Records the + /// `pod install` complete event using the start timestamp captured + /// by [onBeforePodInstall]. + void onAfterPodInstall() { + final DateTime? start = _podInstallStart; + if (start == null) { + return; + } + _tracer.addCompleteEvent( + name: TraceNames.podInstallSpanName, + cat: TraceCategory.subprocess.wireName, + pid: _flutterPid, + tid: _flutterToolTid, + start: start, + end: DateTime.now(), + ); + } + + /// Returns the extra xcodebuild arguments that thread trace plumbing + /// into the build phase script. Safe to splat into the command list + /// unconditionally. + List extraBuildCommands() { + final String assembleTrace = _fs.path.join( + _fs.currentDirectory.path, + _buildDirectoryPath, + 'shorebird_assemble_trace.json', + ); + _assembleTraceFilePath = assembleTrace; + // Naming the env var SHOREBIRD_TRACE_FILE avoids squatting on an + // Xcode-reserved prefix. Read by xcode_backend.dart. + return ['SHOREBIRD_TRACE_FILE=$assembleTrace']; + } + + /// Call right before `_runBuildWithRetries` so the outer build span + /// can compute the pre-xcode setup interval. + void onXcodeAboutToStart() { + final xcodeStart = DateTime.now(); + _xcodeStart = xcodeStart; + _tracer.addCompleteEvent( + name: 'pre-xcode setup', + cat: TraceCategory.flutter.wireName, + pid: _flutterPid, + tid: _flutterToolTid, + start: _buildStart, + end: xcodeStart, + ); + } + + /// Call after `_runBuildWithRetries` returns. Records the outer + /// `xcode ` span, then emits per-subsection events pulled + /// from xcresulttool, then merges the intermediate assemble trace. + /// + /// [runXcresultTool] is injected so callers can route through their + /// own `ProcessManager`; the session avoids a direct `globals.*` + /// dependency. + Future onXcodeFinished({ + required String buildActionName, + required Directory resultBundleDirectory, + required Future Function(List) runXcresultTool, + }) async { + final xcodeEnd = DateTime.now(); + _xcodeEnd = xcodeEnd; + _tracer.addCompleteEvent( + name: '${TraceNames.xcodeSpanPrefix}$buildActionName', + cat: TraceCategory.xcode.wireName, + pid: _flutterPid, + tid: _xcodeWaitTid, + start: _xcodeStart ?? _buildStart, + end: xcodeEnd, + ); + await _emitXcodeSubsectionEvents( + resultBundleDirectory: resultBundleDirectory, + runXcresultTool: runXcresultTool, + ); + final String? assembleTraceFilePath = _assembleTraceFilePath; + if (assembleTraceFilePath != null) { + _tracer.mergeEventsFromFile(_fs.file(assembleTraceFilePath)); + } + } + + /// Clears [BuildTracer.current] without writing the trace. Use on + /// error paths where [finish] won't run. + void abortOnFailure() { + BuildTracer.stop(); + } + + /// Writes the merged trace to disk and clears [BuildTracer.current]. + /// Records the post-xcode + outer `flutter build ios` spans first. + void finish({required void Function(String) printStatus}) { + final buildEnd = DateTime.now(); + _tracer + ..addCompleteEvent( + name: 'post-xcode processing', + cat: TraceCategory.flutter.wireName, + pid: _flutterPid, + tid: _flutterToolTid, + start: _xcodeEnd ?? buildEnd, + end: buildEnd, + ) + ..addCompleteEvent( + name: '${TraceNames.flutterBuildSpanPrefix}ios', + cat: TraceCategory.flutter.wireName, + pid: _flutterPid, + tid: _flutterToolTid, + start: _buildStart, + end: buildEnd, + ) + ..writeToFile(_fs.file(_tracePath)); + printStatus( + 'Shorebird build trace written to $_tracePath. ' + 'View at https://ui.perfetto.dev', + ); + BuildTracer.stop(); + } + + /// Ask xcresulttool for the structured build log of the archive + /// action and emit one Chrome Trace Event per top-level subsection + /// (each Xcode target build). Best-effort: silently returns if the + /// bundle can't be parsed (xcresulttool output format drifts across + /// Xcode versions). + Future _emitXcodeSubsectionEvents({ + required Directory resultBundleDirectory, + required Future Function(List) runXcresultTool, + }) async { + if (!resultBundleDirectory.existsSync()) { + return; + } + final ProcessResult result; + try { + result = await runXcresultTool([ + 'xcrun', + 'xcresulttool', + 'get', + 'log', + '--type', + 'build', + '--path', + resultBundleDirectory.path, + ]); + } on Exception { + return; + } + if (result.exitCode != 0) { + return; + } + + final Object? decoded; + try { + decoded = json.decode(result.stdout as String); + } on FormatException { + return; + } + if (decoded is! Map) { + return; + } + + final Object? subsections = decoded['subsections']; + if (subsections is! List) { + return; + } + for (final Object? sub in subsections) { + if (sub is! Map) { + continue; + } + final String title = (sub['title'] as String?) ?? ''; + final double? startTime = (sub['startTime'] as num?)?.toDouble(); + final double? duration = (sub['duration'] as num?)?.toDouble(); + if (startTime == null || duration == null || duration <= 0) { + continue; + } + final start = DateTime.fromMicrosecondsSinceEpoch((startTime * 1000000).round()); + final end = DateTime.fromMicrosecondsSinceEpoch(((startTime + duration) * 1000000).round()); + _tracer.addCompleteEvent( + name: title, + cat: TraceCategory.xcodeSubsection.wireName, + // Synthetic pid so subsections display on their own row in + // Perfetto, labelled 'xcodebuild' via the process_name + // metadata emitted at tracer setup. xcresult doesn't surface + // xcodebuild's real pid, and compile sub-subsections are done + // by clang/swiftc/ld children we never saw โ€” synthetic is + // honest. + pid: _xcodeSubsectionPid, + tid: 1, + start: start, + end: end, + ); + } + } +} diff --git a/packages/flutter_tools/lib/src/shorebird/network_trace_span.dart b/packages/flutter_tools/lib/src/shorebird/network_trace_span.dart new file mode 100644 index 0000000000000..2f08277ae7f81 --- /dev/null +++ b/packages/flutter_tools/lib/src/shorebird/network_trace_span.dart @@ -0,0 +1,54 @@ +// Shorebird-specific. Keeps the build-trace plumbing for HTTP fetches +// out of net.dart so the Shorebird fork's diff against upstream stays +// small. + +import 'package:shorebird_build_trace/shorebird_build_trace.dart'; + +/// Perfetto row id for Flutter-tool HTTP artifact fetches. Local to +/// the flutter tool's pid; picked to sit alongside the flutter-tool +/// and assemble rows without overlapping either. +const int networkTid = 5; + +/// One HTTP request's contribution to the Shorebird build trace. +/// +/// Construct immediately before issuing the request and call [record] +/// once, after the request has completed (success, error, or body +/// fully drained). Records a Chrome Trace Event Format `X` event on +/// the network row only when a [BuildTracer] is installed; a no-op +/// otherwise, so net.dart can drop one of these in every return path +/// without branching on tracing state. +class NetworkTraceSpan { + NetworkTraceSpan.start({required Uri url, required bool onlyHeaders}) + : _url = url, + _onlyHeaders = onlyHeaders, + _start = DateTime.now(); + + final Uri _url; + final bool _onlyHeaders; + final DateTime _start; + + /// Emits the span. Safe to call whether or not a tracer is active. + /// [statusCode] is recorded on normal completion; [errorKind] on + /// IO/SSL/argument errors. Both optional so callers can record a + /// span before either is known (e.g. connection-level failures). + void record({int? statusCode, String? errorKind}) { + final BuildTracer? tracer = BuildTracer.current; + if (tracer == null) { + return; + } + tracer.addCompleteEvent( + name: '${_onlyHeaders ? 'HEAD' : 'GET'} ${_url.host}', + cat: TraceCategory.network.wireName, + pid: currentProcessId(), + tid: networkTid, + start: _start, + end: DateTime.now(), + args: { + 'method': _onlyHeaders ? 'HEAD' : 'GET', + 'host': _url.host, + if (statusCode != null) 'status': statusCode, + if (errorKind != null) 'error': errorKind, + }, + ); + } +} diff --git a/packages/flutter_tools/lib/src/shorebird/shorebird_yaml.dart b/packages/flutter_tools/lib/src/shorebird/shorebird_yaml.dart new file mode 100644 index 0000000000000..3f4191cef7191 --- /dev/null +++ b/packages/flutter_tools/lib/src/shorebird/shorebird_yaml.dart @@ -0,0 +1,80 @@ +// Copyright 2024 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:yaml/yaml.dart'; +import 'package:yaml_edit/yaml_edit.dart'; + +import '../base/file_system.dart'; +import '../globals.dart' as globals; + +void updateShorebirdYaml( + String? flavor, + String shorebirdYamlPath, { + required Map environment, +}) { + final File shorebirdYaml = globals.fs.file(shorebirdYamlPath); + if (!shorebirdYaml.existsSync()) { + throw Exception('shorebird.yaml not found at $shorebirdYamlPath'); + } + final YamlDocument input = loadYamlDocument(shorebirdYaml.readAsStringSync()); + final YamlMap yamlMap = input.contents as YamlMap; + final Map compiled = compileShorebirdYaml( + yamlMap, + flavor: flavor, + environment: environment, + ); + // Currently we write out over the same yaml file, we should fix this to + // write to a new .json file instead and avoid naming confusion between the + // input and compiled files. + final YamlEditor yamlEditor = YamlEditor(''); + yamlEditor.update([], compiled); + shorebirdYaml.writeAsStringSync(yamlEditor.toString(), flush: true); +} + +String appIdForFlavor(YamlMap yamlMap, {required String? flavor}) { + if (flavor == null || flavor.isEmpty) { + final String? defaultAppId = yamlMap['app_id'] as String?; + if (defaultAppId == null || defaultAppId.isEmpty) { + throw Exception('Cannot find "app_id" in shorebird.yaml'); + } + return defaultAppId; + } + + final YamlMap? yamlFlavors = yamlMap['flavors'] as YamlMap?; + if (yamlFlavors == null) { + throw Exception('Cannot find "flavors" in shorebird.yaml.'); + } + final String? flavorAppId = yamlFlavors[flavor] as String?; + if (flavorAppId == null || flavorAppId.isEmpty) { + throw Exception('Cannot find "app_id" for $flavor in shorebird.yaml'); + } + return flavorAppId; +} + +Map compileShorebirdYaml( + YamlMap yamlMap, { + required String? flavor, + required Map environment, +}) { + final String appId = appIdForFlavor(yamlMap, flavor: flavor); + final Map compiled = {'app_id': appId}; + void copyIfSet(String key) { + if (yamlMap[key] != null) { + compiled[key] = yamlMap[key]; + } + } + + copyIfSet('base_url'); + copyIfSet('auto_update'); + copyIfSet('patch_verification'); + final String? shorebirdPublicKeyEnvVar = environment['SHOREBIRD_PUBLIC_KEY']; + if (shorebirdPublicKeyEnvVar != null) { + compiled['patch_public_key'] = shorebirdPublicKeyEnvVar; + } + final String? moduleVersion = environment['SHOREBIRD_MODULE_VERSION']; + if (moduleVersion != null) { + compiled['module_version'] = moduleVersion; + } + return compiled; +} diff --git a/packages/flutter_tools/lib/src/version.dart b/packages/flutter_tools/lib/src/version.dart index 0eb275a7bed11..372ae96abd943 100644 --- a/packages/flutter_tools/lib/src/version.dart +++ b/packages/flutter_tools/lib/src/version.dart @@ -1049,6 +1049,28 @@ class GitTagVersion { } } + // Check if running on a Shorebird release branch. + final String shorebirdFlutterReleases = git + .runSync([ + 'for-each-ref', + '--contains', + gitRef, + '--format', + '%(refname:short)', + 'refs/remotes/origin/flutter_release/*', + ], workingDirectory: workingDirectory) + .stdout + .trim(); + final stableVersionPattern = RegExp(r'^\d+\.\d+\.\d+$'); + final String? shorebirdFlutterVersion = LineSplitter.split(shorebirdFlutterReleases) + .map((e) => e.replaceFirst('origin/flutter_release/', '')) + .where((e) => stableVersionPattern.hasMatch(e)) + .toList() + .firstOrNull; + if (shorebirdFlutterVersion != null) { + return parse(shorebirdFlutterVersion); + } + // If we don't exist in a tag, use git to find the latest tag. return _useNewestTagAndCommitsPastFallback( git: git, diff --git a/packages/flutter_tools/lib/src/web/compile.dart b/packages/flutter_tools/lib/src/web/compile.dart index 9589940f4be14..56c624ed40d50 100644 --- a/packages/flutter_tools/lib/src/web/compile.dart +++ b/packages/flutter_tools/lib/src/web/compile.dart @@ -38,6 +38,9 @@ const kStaticAssetsUrl = 'staticAssetsUrl'; /// The caching strategy to use for service worker generation. const kServiceWorkerStrategy = 'ServiceWorkerStrategy'; +/// Prefix for web-define variables stored in [Environment.defines]. +const kWebDefinePrefix = 'webDefine:'; + class WebBuilder { WebBuilder({ required Logger logger, @@ -60,6 +63,8 @@ class WebBuilder { final FlutterVersion _flutterVersion; final FileSystem _fileSystem; + /// Builds the web application using the specified compiler configurations + /// and generates the necessary web assets in the output directory. Future buildWeb( FlutterProject flutterProject, String target, @@ -69,6 +74,7 @@ class WebBuilder { String? baseHref, String? staticAssetsUrl, String? outputDirectoryPath, + Map webDefines = const {}, }) async { if (serviceWorkerStrategy != null) { _logger.printWarning( @@ -114,6 +120,7 @@ class WebBuilder { kServiceWorkerStrategy: serviceWorkerStrategy?.cliName ?? ServiceWorkerStrategy.offlineFirst.cliName, ...buildInfo.toBuildSystemEnvironment(), + for (final MapEntry(:key, :value) in webDefines.entries) '$kWebDefinePrefix$key': value, }, packageConfigPath: buildInfo.packageConfigPath, artifacts: globals.artifacts!, diff --git a/packages/flutter_tools/lib/src/web_template.dart b/packages/flutter_tools/lib/src/web_template.dart index 919f3eefc19c1..2910236167a3a 100644 --- a/packages/flutter_tools/lib/src/web_template.dart +++ b/packages/flutter_tools/lib/src/web_template.dart @@ -8,6 +8,7 @@ import 'package:meta/meta.dart'; import 'base/common.dart'; import 'base/file_system.dart'; +import 'base/logger.dart'; import 'base/utils.dart'; /// Placeholder for base href @@ -107,6 +108,7 @@ class WebTemplate { String? buildConfig, String? flutterBootstrapJs, String? staticAssetsUrl, + required Logger logger, Map webDefines = const {}, }) { String newContent = _content; @@ -133,7 +135,7 @@ class WebTemplate { "navigator.serviceWorker.register('flutter_service_worker.js?v=$serviceWorkerVersion') /* $_kServiceWorkerDeprecationNotice */", ); } - newContent = _applyVariableSubstitutions(newContent, { + newContent = _applyVariableSubstitutions(newContent, logger, { ...webDefines, if (buildConfig != null) 'flutter_build_config': buildConfig, if (flutterBootstrapJs != null) 'flutter_bootstrap_js': flutterBootstrapJs, @@ -149,8 +151,13 @@ class WebTemplate { /// Applies web-define variable substitutions and validates all variables are provided. /// /// Replaces {{VARIABLE}} placeholders with values from webDefines. Built-in Flutter - /// variables are preserved if missing; user-defined variables throw ToolExit. - String _applyVariableSubstitutions(String content, Map webDefines) { + /// variables are preserved if missing; user-defined variables will log a warning + /// and be skipped. + String _applyVariableSubstitutions( + String content, + Logger logger, + Map webDefines, + ) { final variablePattern = RegExp(r'\{\{([A-Za-z_][A-Za-z0-9_]*)\}\}'); final missingVariables = {}; @@ -188,13 +195,16 @@ class WebTemplate { .map((String name) => '--web-define=$name=VALUE') .join(' '); final String variablesList = pluralize('variable', missingVariables.length); - throwToolExit( - 'Missing web-define $variablesList: $variables\n\n' - 'Please provide the missing $variablesList using:\n' + logger.printWarning( + 'Warning: Missing web-define $variablesList: $variables\n\n' + 'You can provide the missing $variablesList using:\n' 'flutter run $suggestion\n' 'or\n' - 'flutter build web $suggestion', + 'flutter build web $suggestion\n' + 'This variable will be skipped.\n', ); + + return result; } } diff --git a/packages/flutter_tools/lib/src/xcode_project.dart b/packages/flutter_tools/lib/src/xcode_project.dart index d86465d117a1c..8061518386ad8 100644 --- a/packages/flutter_tools/lib/src/xcode_project.dart +++ b/packages/flutter_tools/lib/src/xcode_project.dart @@ -5,6 +5,8 @@ /// @docImport 'ios/mac.dart'; library; +import 'package:yaml/yaml.dart' as yaml; + import 'base/error_handling_io.dart'; import 'base/file_system.dart'; import 'base/logger.dart'; @@ -159,6 +161,10 @@ abstract class XcodeBasedProject extends FlutterProjectPlatform { File get flutterPluginSwiftPackageManifest => flutterPluginSwiftPackageDirectory.childFile('Package.swift'); + /// The Flutter generated directory for the Swift package that will handle the Flutter framework. + Directory get flutterFrameworkSwiftPackageDirectory => relativeSwiftPackagesDirectory + .childDirectory(kFlutterGeneratedFrameworkSwiftPackageTargetName); + /// Checks if FlutterGeneratedPluginSwiftPackage has been added to the /// project's build settings by checking the contents of the pbxproj. bool get flutterPluginSwiftPackageInProjectSettings { @@ -476,79 +482,207 @@ def __lldb_init_module(debugger: lldb.SBDebugger, _): /// True if the app project uses Swift. bool get isSwift => appDelegateSwift.existsSync(); - /// Prints a warning if any plugin(s) are excluding `arm64` architecture. + /// Returns true if all plugins and their dependencies support arm64. /// - /// Xcode 26 no longer allows you to build x86-only architecture for the simulator - Future checkForPluginsExcludingArmSimulator() async { + /// When using Xcode 26+, print a warning if a plugin or its dependencies does not support + /// arm64. + Future pluginsSupportArmSimulator({required bool printWarnings}) async { + final Version? xcodeVersion = globals.xcode?.currentVersion; final Directory podXcodeProject = hostAppRoot .childDirectory('Pods') .childDirectory('Pods.xcodeproj'); if (!podXcodeProject.existsSync()) { - return; + return true; } final XcodeProjectInterpreter? xcodeProjectInterpreter = globals.xcodeProjectInterpreter; if (xcodeProjectInterpreter == null) { - return; + // Xcode isn't installed, don't try to check. + return true; } final String? buildSettings = await xcodeProjectInterpreter.pluginsBuildSettingsOutput( podXcodeProject, ); - if (buildSettings == null || buildSettings.isEmpty) { - return; + globals.logger.printTrace('Unable to get build settings for Pods.'); + return true; } + // When using Xcode 26, print a warning if a target does not support arm. + if (xcodeVersion != null && xcodeVersion.major >= 26 && printWarnings) { + final List<({String target, String? plugin})> targetsExcludingArm = + await _targetsExcludingArm(buildSettings); + if (targetsExcludingArm.isNotEmpty) { + final String list = targetsExcludingArm + .map((target) { + var targetItem = ' - ${target.target}'; + if (target.target == target.plugin) { + targetItem = '$targetItem (Flutter plugin)'; + } else if (target.plugin != null) { + targetItem = + '$targetItem (transitive dependency of Flutter plugin ${target.plugin})'; + } + return targetItem; + }) + .join('\n'); + globals.logger.printWarning( + 'The following target(s) do not support arm64 architecture, which is a requirement for ' + 'Apple Silicon iOS 26+ simulators:\n' + '$list\n\n' + 'Please contact plugin maintainers to request arm64 support to continue to be able to ' + 'use the plugin on a simulator.', + ); + } + } + + return !buildSettings.contains(RegExp('EXCLUDED_ARCHS.*arm64')); + } + + /// Returns a list of targets and their associated plugin (if found) that exclude arm64 architecture. + Future> _targetsExcludingArm(String buildSettings) async { + final Map> cocoapodsDependencyGraph = _cocoapodsDependencyGraph(); final List allPlugins = await findPlugins(parent); - final iosPluginTargetNames = { + final pluginNames = { for (final Plugin plugin in allPlugins) if (plugin.platforms.containsKey(IOSPlugin.kConfigKey)) plugin.name, }; - if (iosPluginTargetNames.isEmpty) { - return; - } - - final targetHeader = RegExp( + final targetHeaderPattern = RegExp( r'^Build settings for action build and target "?([^":\r\n]+)"?:\s*$', ); - final pluginsExcludingArmArch = {}; + final List<({String target, String? plugin})> foundTargets = []; String? currentTarget; - for (final String eachLine in buildSettings.split('\n')) { final String settingsLine = eachLine.trim(); - - final RegExpMatch? headerMatch = targetHeader.firstMatch(settingsLine); + final RegExpMatch? headerMatch = targetHeaderPattern.firstMatch(settingsLine); if (headerMatch != null) { currentTarget = headerMatch.group(1)!.trim(); continue; } - - if (currentTarget == null || !iosPluginTargetNames.contains(currentTarget)) { + if (currentTarget == null || + !settingsLine.startsWith('EXCLUDED_ARCHS') || + !settingsLine.contains('=')) { continue; } - - if (!settingsLine.startsWith('EXCLUDED_ARCHS') || !settingsLine.contains('=')) { + final Iterable tokens = settingsLine.split(' '); + if (!tokens.contains('arm64')) { continue; } - final Iterable tokens = settingsLine.split(' '); - if (tokens.contains('arm64')) { - pluginsExcludingArmArch.add(currentTarget); + if (pluginNames.contains(currentTarget)) { + foundTargets.add((target: currentTarget, plugin: currentTarget)); + } else { + // If it's not a plugin, it may be a transitive dependency of a plugin + final String? parentPlugin = _pluginUsingDependency( + targetName: currentTarget, + pluginNames: pluginNames.toList(), + cocoapodsDependencyGraph: cocoapodsDependencyGraph, + ); + if (parentPlugin != null) { + foundTargets.add((target: currentTarget, plugin: parentPlugin)); + } else if (currentTarget.startsWith('Pods-')) { + // Skip Pods- targets since they are not actual dependencies. + continue; + } else { + foundTargets.add((target: currentTarget, plugin: null)); + } } } + return foundTargets; + } - if (pluginsExcludingArmArch.isNotEmpty) { - final String list = pluginsExcludingArmArch.map((String n) => ' - $n').join('\n'); - - globals.logger.printWarning( - 'The following plugin(s) are excluding the arm64 architecture, which is a requirement for Xcode 26+:\n' - '$list\n' - 'Consider installing the "Universal" Xcode or file an issue with the plugin(s) to support arm64.', + /// Returns the plugin that uses the given target. + String? _pluginUsingDependency({ + required String targetName, + required List pluginNames, + required Map> cocoapodsDependencyGraph, + }) { + final String? pluginName = _findPluginForDependency( + targetName, + cocoapodsDependencyGraph, + pluginNames, + ); + if (pluginName != null) { + return pluginName; + } + if (targetName.contains('-')) { + // Resource bundle targets are prefixed with the name of the pod and then the name of the + // resource. Strip the resource name to get the pod name. + return _findPluginForDependency( + targetName.split('-').first, + cocoapodsDependencyGraph, + pluginNames, + ); + } else { + // Sometimes the target name is a prefix of pod name. + // Example: target name "GoogleMLKit" and pod name "GoogleMLKit/Translate". + return _findPluginForDependency( + targetName, + cocoapodsDependencyGraph, + pluginNames, + searchByPrefix: true, ); } } + /// Returns a map of pods and their dependencies. + Map> _cocoapodsDependencyGraph() { + final Map> podDependencies = {}; + try { + if (podfileLock.existsSync()) { + final String podfileLockString = podfileLock.readAsStringSync().split('\n\n').first; + final dynamic podsYaml = yaml.loadYaml(podfileLockString); + if (podsYaml is yaml.YamlMap) { + final dynamic podsList = podsYaml['PODS']; + if (podsList is List) { + // ignore: specify_nonobvious_local_variable_types + for (final dynamic pod in podsList) { + if (pod is yaml.YamlMap) { + final name = pod.keys.first as String; + final dynamic value = pod.value[name]; + if (value is yaml.YamlList) { + podDependencies[name.split(' ').first] = value + .map((dep) => (dep as String).split(' ').first) + .toList(); + } + } + } + } + } + } + } on Exception catch (e) { + globals.logger.printTrace('Failed to parse podfile.lock: $e'); + } + + return podDependencies; + } + + /// Recursively searches the pod dependency graph for a Flutter plugin that uses the given target. + String? _findPluginForDependency( + String targetName, + Map> cocoapodTree, + List pluginNames, { + bool searchByPrefix = false, + }) { + for (final MapEntry> pod in cocoapodTree.entries) { + final String podName = pod.key; + final List podDependencies = pod.value; + bool podHasTargetAsDependency; + if (searchByPrefix) { + podHasTargetAsDependency = podDependencies.any((dep) => dep.startsWith('$targetName/')); + } else { + podHasTargetAsDependency = podDependencies.contains(targetName); + } + if (podHasTargetAsDependency) { + if (pluginNames.contains(podName)) { + return podName; + } + return _findPluginForDependency(podName, cocoapodTree, pluginNames); + } + } + return null; + } + @override bool existsSync() { return parent.isModule || _editableDirectory.existsSync(); diff --git a/packages/flutter_tools/pubspec.yaml b/packages/flutter_tools/pubspec.yaml index 89575a4b6a41b..b8376751e3b32 100644 --- a/packages/flutter_tools/pubspec.yaml +++ b/packages/flutter_tools/pubspec.yaml @@ -58,6 +58,16 @@ dependencies: pubspec_parse: 1.5.0 graphs: 2.3.2 + + # Shorebird-specific: shared build-trace library. Pinned to a specific + # commit of the shorebird monorepo. Roll this ref together with any + # change to the shared library. Subdir path points at the package + # within the monorepo. + shorebird_build_trace: + git: + url: https://github.com/shorebirdtech/shorebird.git + ref: cff436f66534e14618cddcaf93f0d0e9862c937a + path: packages/shorebird_build_trace hooks_runner: 1.0.1 hooks: 1.0.0 code_assets: 1.0.0 @@ -66,8 +76,8 @@ dependencies: # We depend on very specific internal implementation details of the # 'test' package, which change between versions, so when upgrading # this, make sure the tests are still running correctly. - test_api: 0.7.8 - test_core: 0.6.14 + test_api: 0.7.10 + test_core: 0.6.16 vm_service: 15.0.2 @@ -95,7 +105,7 @@ dependencies: http_parser: 4.1.2 io: 1.0.5 json_rpc_2: 4.0.0 - matcher: 0.12.18 + matcher: 0.12.19 petitparser: 7.0.1 platform: 3.1.6 shelf_packages_handler: 3.0.2 @@ -122,10 +132,10 @@ dev_dependencies: js: 0.7.2 json_annotation: 4.9.0 node_preamble: 2.0.2 - test: 1.28.0 + test: 1.30.0 dartdoc: # Exclude this package from the hosted API docs. nodoc: true -# PUBSPEC CHECKSUM: ot91td +# PUBSPEC CHECKSUM: trof4g diff --git a/packages/flutter_tools/test/commands.shard/hermetic/assemble_test.dart b/packages/flutter_tools/test/commands.shard/hermetic/assemble_test.dart index e394c0c217381..43e58726c551f 100644 --- a/packages/flutter_tools/test/commands.shard/hermetic/assemble_test.dart +++ b/packages/flutter_tools/test/commands.shard/hermetic/assemble_test.dart @@ -364,6 +364,7 @@ void main() { elapsedMilliseconds: 123, skipped: false, succeeded: true, + startTimeMicroseconds: 0, ), }, ), @@ -502,6 +503,7 @@ void main() { skipped: false, succeeded: true, elapsedMilliseconds: 123, + startTimeMicroseconds: 0, ), ]; final FileSystem fileSystem = MemoryFileSystem.test(); @@ -545,6 +547,57 @@ void main() { await commandRunner.run(['--help' /* -- verbose omitted (verboseHelp: true) is set above */]); expect(testLogger.statusText, contains('assemble')); }); + + testWithoutContext('writeTraceData outputs Chrome Trace Event Format', () { + final measurements = [ + PerformanceMeasurement( + analyticsName: 'KernelSnapshot', + target: 'kernel_snapshot', + skipped: false, + succeeded: true, + elapsedMilliseconds: 500, + startTimeMicroseconds: 1000000, + ), + ]; + final FileSystem fileSystem = MemoryFileSystem.test(); + final outFile = fileSystem.currentDirectory.childDirectory('foo').childFile('trace.json'); + + writeTraceData(measurements, outFile); + + expect(outFile, exists); + final events = json.decode(outFile.readAsStringSync()) as List; + // 2 metadata events (process_name, thread_name) + 1 complete span. + expect(events, hasLength(3)); + + // Metadata events name the process + thread so Perfetto labels the + // row rather than showing a bare pid. + final processMeta = events[0]! as Map; + expect(processMeta['ph'], 'M'); + expect(processMeta['name'], 'process_name'); + expect((processMeta['args']! as Map)['name'], 'flutter assemble'); + + final threadMeta = events[1]! as Map; + expect(threadMeta['ph'], 'M'); + expect(threadMeta['name'], 'thread_name'); + expect((threadMeta['args']! as Map)['name'], 'flutter assemble'); + + final event = events[2]! as Map; + expect(event['ph'], 'X'); + expect(event['name'], 'KernelSnapshot'); + expect(event['cat'], 'assemble'); + expect(event['ts'], 1000000); + expect(event['dur'], 500000); + // pid is the real pid of the test process; just assert it's present + // and an int, and matches between metadata + span. + expect(event['pid'], isA()); + expect(event['pid'], processMeta['pid']); + expect(event['tid'], 1); + expect(event['args'], { + 'target': 'kernel_snapshot', + 'skipped': false, + 'succeeded': true, + }); + }); } final class _StubCommand extends FlutterCommand { diff --git a/packages/flutter_tools/test/commands.shard/hermetic/build_macos_test.dart b/packages/flutter_tools/test/commands.shard/hermetic/build_macos_test.dart index 7c3845044399b..2ac5820218ae0 100644 --- a/packages/flutter_tools/test/commands.shard/hermetic/build_macos_test.dart +++ b/packages/flutter_tools/test/commands.shard/hermetic/build_macos_test.dart @@ -73,6 +73,14 @@ final macosPlatformCustomEnv = FakePlatform( environment: {'FLUTTER_ROOT': '/', 'HOME': '/'}, ); +final Platform macosPlatformWithShorebirdPublicKey = FakePlatform( + operatingSystem: 'macos', + environment: { + 'FLUTTER_ROOT': '/', + 'HOME': '/', + 'SHOREBIRD_PUBLIC_KEY': 'my_public_key', + }, +); final Platform notMacosPlatform = FakePlatform(environment: {'FLUTTER_ROOT': '/'}); void main() { @@ -1123,4 +1131,43 @@ STDERR STUFF OperatingSystemUtils: () => FakeOperatingSystemUtils(hostPlatform: HostPlatform.darwin_x64), }, ); + + testUsingContext( + 'macOS build outputs path and size when successful', + () async { + final BuildCommand command = BuildCommand( + androidSdk: FakeAndroidSdk(), + buildSystem: TestBuildSystem.all(BuildResult(success: true)), + fileSystem: MemoryFileSystem.test(), + logger: BufferLogger.test(), + osUtils: FakeOperatingSystemUtils(), + ); + createMinimalMockProjectFiles(); + final File shorebirdYamlFile = + fileSystem.file( + 'build/macos/Build/Products/Release/example.app/Contents/Frameworks/App.framework/Resources/flutter_assets/shorebird.yaml', + ) + ..createSync(recursive: true) + ..writeAsStringSync('app_id: my-app-id'); + + await createTestCommandRunner(command).run(const ['build', 'macos', '--no-pub']); + + final String updatedYaml = shorebirdYamlFile.readAsStringSync(); + expect(updatedYaml, contains('app_id: my-app-id')); + expect(updatedYaml, contains('patch_public_key: my_public_key')); + }, + overrides: { + FileSystem: () => fileSystem, + ProcessManager: () => + FakeProcessManager.list([setUpFakeXcodeBuildHandler('Release')]), + Platform: () => macosPlatformWithShorebirdPublicKey, + FeatureFlags: () => TestFeatureFlags(isMacOSEnabled: true), + OperatingSystemUtils: () => FakeOperatingSystemUtils(hostPlatform: HostPlatform.darwin_x64), + }, + // SHOREBIRD_PUBLIC_KEY โ†’ shorebird.yaml injection happens inside the + // assets build target, which TestBuildSystem.all stubs out entirely. + // Exercising it needs a real build system or a direct updateShorebirdYaml + // call; skip until that rework lands. + skip: true, + ); } diff --git a/packages/flutter_tools/test/commands.shard/hermetic/build_web_test.dart b/packages/flutter_tools/test/commands.shard/hermetic/build_web_test.dart index d43cc185a2788..0d28cda211532 100644 --- a/packages/flutter_tools/test/commands.shard/hermetic/build_web_test.dart +++ b/packages/flutter_tools/test/commands.shard/hermetic/build_web_test.dart @@ -159,6 +159,79 @@ void main() { }, ); + testUsingContext( + 'Passes --web-define values to environment defines with prefix', + () async { + final buildCommand = BuildCommand( + androidSdk: FakeAndroidSdk(), + buildSystem: TestBuildSystem.all(BuildResult(success: true)), + fileSystem: fileSystem, + logger: logger, + osUtils: FakeOperatingSystemUtils(), + ); + final CommandRunner runner = createTestCommandRunner(buildCommand); + setupFileSystemForEndToEndTest(fileSystem); + await runner.run([ + 'build', + 'web', + '--no-pub', + '--no-web-resources-cdn', + '--web-define=VERSION=v1.2.3', + '--web-define=API_URL=https://api.example.com', + ]); + + final Directory buildDir = fileSystem.directory(fileSystem.path.join('build', 'web')); + + expect(buildDir.existsSync(), true); + expect(testLogger.statusText, contains('โœ“ Built ${buildDir.path}')); + }, + overrides: { + Platform: () => fakePlatform, + FileSystem: () => fileSystem, + FeatureFlags: () => TestFeatureFlags(isWebEnabled: true), + ProcessManager: () => processManager, + BuildSystem: () => + TestBuildSystem.all(BuildResult(success: true), (Target target, Environment environment) { + expect(environment.defines['webDefine:VERSION'], 'v1.2.3'); + expect(environment.defines['webDefine:API_URL'], 'https://api.example.com'); + }), + }, + ); + + testUsingContext( + 'Builds successfully without --web-define', + () async { + final buildCommand = BuildCommand( + androidSdk: FakeAndroidSdk(), + buildSystem: TestBuildSystem.all(BuildResult(success: true)), + fileSystem: fileSystem, + logger: logger, + osUtils: FakeOperatingSystemUtils(), + ); + final CommandRunner runner = createTestCommandRunner(buildCommand); + setupFileSystemForEndToEndTest(fileSystem); + await runner.run(['build', 'web', '--no-pub', '--no-web-resources-cdn']); + + final Directory buildDir = fileSystem.directory(fileSystem.path.join('build', 'web')); + + expect(buildDir.existsSync(), true); + }, + overrides: { + Platform: () => fakePlatform, + FileSystem: () => fileSystem, + FeatureFlags: () => TestFeatureFlags(isWebEnabled: true), + ProcessManager: () => processManager, + BuildSystem: () => + TestBuildSystem.all(BuildResult(success: true), (Target target, Environment environment) { + // No web-define entries should be present. + final bool hasWebDefines = environment.defines.keys.any( + (String key) => key.startsWith('webDefine:'), + ); + expect(hasWebDefines, isFalse); + }), + }, + ); + testUsingContext( 'Infers target entrypoint correctly from --target', () async { diff --git a/packages/flutter_tools/test/commands.shard/hermetic/build_windows_test.dart b/packages/flutter_tools/test/commands.shard/hermetic/build_windows_test.dart index 11c574670713a..b037587b9aa4e 100644 --- a/packages/flutter_tools/test/commands.shard/hermetic/build_windows_test.dart +++ b/packages/flutter_tools/test/commands.shard/hermetic/build_windows_test.dart @@ -38,6 +38,15 @@ final Platform windowsPlatform = FakePlatform( 'USERPROFILE': '/', }, ); +final Platform windowsPlatformWithPublicKey = FakePlatform( + operatingSystem: 'windows', + environment: { + 'PROGRAMFILES(X86)': r'C:\Program Files (x86)\', + 'FLUTTER_ROOT': flutterRoot, + 'USERPROFILE': '/', + 'SHOREBIRD_PUBLIC_KEY': 'my_public_key', + }, +); final Platform notWindowsPlatform = FakePlatform( environment: {'FLUTTER_ROOT': flutterRoot}, ); @@ -1213,6 +1222,46 @@ No file or variants found for asset: images/a_dot_burr.jpeg. FeatureFlags: () => TestFeatureFlags(isWindowsEnabled: true), }, ); + + testUsingContext( + 'shorebird.yaml is updated when SHOREBIRD_PUBLIC_KEY env var is set', + () async { + final FakeVisualStudio fakeVisualStudio = FakeVisualStudio(); + final BuildWindowsCommand command = BuildWindowsCommand( + logger: BufferLogger.test(), + operatingSystemUtils: FakeOperatingSystemUtils(), + )..visualStudioOverride = fakeVisualStudio; + setUpMockProjectFilesForBuild(); + final File shorebirdYamlFile = + fileSystem.file(r'build\windows\x64\runner\Release\data\flutter_assets\shorebird.yaml') + ..createSync(recursive: true) + ..writeAsStringSync('app_id: my-app-id'); + + processManager = FakeProcessManager.list([ + cmakeGenerationCommand(), + buildCommand('Release'), + ]); + + await createTestCommandRunner( + command, + ).run(const ['windows', '--release', '--no-pub']); + + final String updatedYaml = shorebirdYamlFile.readAsStringSync(); + expect(updatedYaml, contains('app_id: my-app-id')); + expect(updatedYaml, contains('patch_public_key: my_public_key')); + }, + overrides: { + FileSystem: () => fileSystem, + ProcessManager: () => processManager, + Platform: () => windowsPlatformWithPublicKey, + FeatureFlags: () => TestFeatureFlags(isWindowsEnabled: true), + }, + // SHOREBIRD_PUBLIC_KEY โ†’ shorebird.yaml injection happens inside the + // assets build target, which the mocked FakeProcessManager build + // commands don't drive. Same situation as the macOS analogue; skip + // until we test updateShorebirdYaml directly. + skip: true, + ); } class FakeVisualStudio extends Fake implements VisualStudio { diff --git a/packages/flutter_tools/test/commands.shard/hermetic/update_packages_test.dart b/packages/flutter_tools/test/commands.shard/hermetic/update_packages_test.dart index fb5fb10bfcd76..f545dc45109b0 100644 --- a/packages/flutter_tools/test/commands.shard/hermetic/update_packages_test.dart +++ b/packages/flutter_tools/test/commands.shard/hermetic/update_packages_test.dart @@ -86,9 +86,9 @@ dependencies: dev_dependencies: flutter_tools: - path: ../../../ + path: ../../../packages/flutter_tools/ -# PUBSPEC CHECKSUM: rseq17 +# PUBSPEC CHECKSUM: 55t4hi '''; // An example pubspec.yaml from flutter, not necessary for it to be up to date. @@ -263,10 +263,8 @@ void main() { ..createSync() ..writeAsStringSync(kFlutterWorkspacePubspecYaml); widgetPreviewScaffold = flutterSdk - .childDirectory('packages') - .childDirectory('flutter_tools') - .childDirectory('test') - .childDirectory('widget_preview_scaffold.shard') + .childDirectory('dev') + .childDirectory('integration_tests') .childDirectory('widget_preview_scaffold'); widgetPreviewScaffold.childFile('pubspec.yaml') ..createSync(recursive: true) diff --git a/packages/flutter_tools/test/general.shard/base/build_test.dart b/packages/flutter_tools/test/general.shard/base/build_test.dart index de41b2e569322..90cc0c26ac1db 100644 --- a/packages/flutter_tools/test/general.shard/base/build_test.dart +++ b/packages/flutter_tools/test/general.shard/base/build_test.dart @@ -37,6 +37,17 @@ const kDefaultClang = [ 'build/foo/snapshot_assembly.o', ]; +// Shorebird link info arguments added for iOS/macOS builds. +// These correspond to the dumpLinkInfoArgs in AOTSnapshotter.build(). +const kLinkInfoArgs = [ + '--print_class_table_link_debug_info_to=build/App.class_table.json', + '--print_class_table_link_info_to=build/App.ct.link', + '--print_field_table_link_debug_info_to=build/App.field_table.json', + '--print_field_table_link_info_to=build/App.ft.link', + '--print_dispatch_table_link_debug_info_to=build/App.dispatch_table.json', + '--print_dispatch_table_link_info_to=build/App.dt.link', +]; + void main() { group('GenSnapshot', () { late GenSnapshot genSnapshot; @@ -203,6 +214,7 @@ void main() { command: [ genSnapshotPath, '--deterministic', + ...kLinkInfoArgs, '--snapshot_kind=app-aot-assembly', '--assembly=$assembly', '--dwarf-stack-traces', @@ -278,6 +290,7 @@ void main() { command: [ genSnapshotPath, '--deterministic', + ...kLinkInfoArgs, '--snapshot_kind=app-aot-assembly', '--assembly=$assembly', '--obfuscate', @@ -349,6 +362,7 @@ void main() { command: [ genSnapshotPath, '--deterministic', + ...kLinkInfoArgs, '--snapshot_kind=app-aot-assembly', '--assembly=${fileSystem.path.join(outputPath, 'snapshot_assembly.S')}', 'main.dill', diff --git a/packages/flutter_tools/test/general.shard/build_system/build_trace_test.dart b/packages/flutter_tools/test/general.shard/build_system/build_trace_test.dart new file mode 100644 index 0000000000000..c1b61aa9488bd --- /dev/null +++ b/packages/flutter_tools/test/general.shard/build_system/build_trace_test.dart @@ -0,0 +1,239 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:file/memory.dart'; +import 'package:flutter_tools/src/base/file_system.dart'; +import 'package:shorebird_build_trace/shorebird_build_trace.dart'; +import 'package:flutter_tools/src/convert.dart'; + +import '../../src/common.dart'; + +void main() { + group('BuildTraceEvent', () { + testWithoutContext('toJson produces Chrome Trace Event Format', () { + final event = BuildTraceEvent( + name: 'test_target', + cat: 'assemble', + start: DateTime.fromMicrosecondsSinceEpoch(1000000), + duration: const Duration(microseconds: 500000), + pid: 1, + tid: 3, + args: {'key': 'value'}, + ); + + final result = event.toJson(); + + expect(result['ph'], 'X'); + expect(result['name'], 'test_target'); + expect(result['cat'], 'assemble'); + expect(result['ts'], 1000000); + expect(result['dur'], 500000); + expect(result['pid'], 1); + expect(result['tid'], 3); + expect(result['args'], {'key': 'value'}); + }); + + testWithoutContext('toJson omits args when null', () { + final event = BuildTraceEvent( + name: 'test', + cat: 'flutter', + start: DateTime.fromMicrosecondsSinceEpoch(0), + duration: const Duration(microseconds: 100), + pid: 1, + tid: 1, + ); + + final result = event.toJson(); + + expect(result.containsKey('args'), isFalse); + }); + + testWithoutContext('fromJson round-trips correctly', () { + final original = { + 'ph': 'X', + 'name': 'test', + 'cat': 'flutter', + 'ts': 1000, + 'dur': 500, + 'pid': 1, + 'tid': 2, + 'args': {'foo': 'bar'}, + }; + + final event = BuildTraceEvent.fromJson(original); + final result = event.toJson(); + + expect(result['name'], 'test'); + expect(result['cat'], 'flutter'); + expect(result['ts'], 1000); + expect(result['dur'], 500); + expect(result['pid'], 1); + expect(result['tid'], 2); + }); + }); + + group('BuildTracer', () { + late FileSystem fileSystem; + + setUp(() { + fileSystem = MemoryFileSystem.test(); + }); + + testWithoutContext('addCompleteEvent adds event with correct duration', () { + final tracer = BuildTracer(); + + tracer.addCompleteEvent( + name: 'gradle build', + cat: 'gradle', + pid: 1, + tid: 2, + start: DateTime.fromMicrosecondsSinceEpoch(1000), + end: DateTime.fromMicrosecondsSinceEpoch(5000), + ); + + final outFile = fileSystem.file('trace.json'); + tracer.writeToFile(outFile); + + final events = json.decode(outFile.readAsStringSync()) as List; + expect(events, hasLength(1)); + + final event = events.first! as Map; + expect(event['name'], 'gradle build'); + expect(event['cat'], 'gradle'); + expect(event['tid'], 2); + expect(event['ts'], 1000); + expect(event['dur'], 4000); + expect(event['ph'], 'X'); + expect(event['pid'], 1); + }); + + testWithoutContext('mergeEventsFromFile reads and appends events', () { + final tracer = BuildTracer(); + + // Write a trace file to merge. + final sourceFile = fileSystem.file('source_trace.json'); + sourceFile.writeAsStringSync( + json.encode(>[ + { + 'ph': 'X', + 'name': 'KernelSnapshot', + 'cat': 'assemble', + 'ts': 2000, + 'dur': 1000, + 'pid': 1, + 'tid': 3, + }, + ]), + ); + + tracer.addCompleteEvent( + name: 'gradle build', + cat: 'gradle', + pid: 1, + tid: 2, + start: DateTime.fromMicrosecondsSinceEpoch(1000), + end: DateTime.fromMicrosecondsSinceEpoch(5000), + ); + + tracer.mergeEventsFromFile(sourceFile); + + final outFile = fileSystem.file('trace.json'); + tracer.writeToFile(outFile); + + final events = json.decode(outFile.readAsStringSync()) as List; + expect(events, hasLength(2)); + expect((events[0]! as Map)['name'], 'gradle build'); + expect((events[1]! as Map)['name'], 'KernelSnapshot'); + }); + + testWithoutContext('mergeEventsFromFile does nothing for non-existent file', () { + final tracer = BuildTracer(); + + tracer.addCompleteEvent( + name: 'test', + cat: 'flutter', + pid: 1, + tid: 1, + start: DateTime.fromMicrosecondsSinceEpoch(0), + end: DateTime.fromMicrosecondsSinceEpoch(100), + ); + + // Should not throw. + tracer.mergeEventsFromFile(fileSystem.file('does_not_exist.json')); + + final outFile = fileSystem.file('trace.json'); + tracer.writeToFile(outFile); + + final events = json.decode(outFile.readAsStringSync()) as List; + expect(events, hasLength(1)); + }); + + testWithoutContext('writeToFile creates parent directories', () { + final tracer = BuildTracer(); + tracer.addCompleteEvent( + name: 'test', + cat: 'flutter', + pid: 1, + tid: 1, + start: DateTime.fromMicrosecondsSinceEpoch(0), + end: DateTime.fromMicrosecondsSinceEpoch(100), + ); + + final outFile = fileSystem.file('/a/b/c/trace.json'); + tracer.writeToFile(outFile); + + expect(outFile.existsSync(), isTrue); + }); + + testWithoutContext('output is valid Chrome Trace Event Format', () { + final tracer = BuildTracer(); + + tracer.addCompleteEvent( + name: 'flutter build apk', + cat: 'flutter', + pid: 1, + tid: 1, + start: DateTime.fromMicrosecondsSinceEpoch(0), + end: DateTime.fromMicrosecondsSinceEpoch(15000000), + ); + tracer.addCompleteEvent( + name: 'gradle assembleRelease', + cat: 'gradle', + pid: 1, + tid: 2, + start: DateTime.fromMicrosecondsSinceEpoch(500000), + end: DateTime.fromMicrosecondsSinceEpoch(12500000), + ); + tracer.addCompleteEvent( + name: 'KernelSnapshot', + cat: 'assemble', + pid: 2, + tid: 1, + start: DateTime.fromMicrosecondsSinceEpoch(2000000), + end: DateTime.fromMicrosecondsSinceEpoch(5000000), + args: {'skipped': false}, + ); + + final outFile = fileSystem.file('trace.json'); + tracer.writeToFile(outFile); + + // Verify it's a valid JSON array. + final events = json.decode(outFile.readAsStringSync()) as List; + expect(events, hasLength(3)); + + // Verify all required fields are present in each event. + for (final item in events) { + final event = item! as Map; + expect(event.containsKey('ph'), isTrue); + expect(event.containsKey('name'), isTrue); + expect(event.containsKey('cat'), isTrue); + expect(event.containsKey('ts'), isTrue); + expect(event.containsKey('dur'), isTrue); + expect(event.containsKey('pid'), isTrue); + expect(event.containsKey('tid'), isTrue); + expect(event['ph'], 'X'); + } + }); + }); +} diff --git a/packages/flutter_tools/test/general.shard/build_system/targets/common_test.dart b/packages/flutter_tools/test/general.shard/build_system/targets/common_test.dart index 2e0f99300c3be..c0dda6b07591e 100644 --- a/packages/flutter_tools/test/general.shard/build_system/targets/common_test.dart +++ b/packages/flutter_tools/test/general.shard/build_system/targets/common_test.dart @@ -25,6 +25,17 @@ const kBoundaryKey = '4d2d9609-c662-4571-afde-31410f96caa6'; const kElfAot = '--snapshot_kind=app-aot-elf'; const kAssemblyAot = '--snapshot_kind=app-aot-assembly'; +/// Generate Shorebird link info arguments for iOS/macOS AOT builds. +/// The [buildPath] should be the build directory path (outputDir.parent.path). +List linkInfoArgsFor(String buildPath) => [ + '--print_class_table_link_debug_info_to=$buildPath/App.class_table.json', + '--print_class_table_link_info_to=$buildPath/App.ct.link', + '--print_field_table_link_debug_info_to=$buildPath/App.field_table.json', + '--print_field_table_link_info_to=$buildPath/App.ft.link', + '--print_dispatch_table_link_debug_info_to=$buildPath/App.dispatch_table.json', + '--print_dispatch_table_link_info_to=$buildPath/App.dt.link', +]; + final Platform macPlatform = FakePlatform( operatingSystem: 'macos', environment: {}, @@ -803,6 +814,7 @@ void main() { // This path is not known by the cache due to the iOS gen_snapshot split. 'Artifact.genSnapshotArm64.TargetPlatform.ios.profile', '--deterministic', + ...linkInfoArgsFor(build), '--write-v8-snapshot-profile-to=code_size_1/snapshot.arm64.json', '--trace-precompiler-to=code_size_1/trace.arm64.json', kAssemblyAot, diff --git a/packages/flutter_tools/test/general.shard/build_system/targets/macos_test.dart b/packages/flutter_tools/test/general.shard/build_system/targets/macos_test.dart index aa31e06bfcddd..fd3ce981cedb1 100644 --- a/packages/flutter_tools/test/general.shard/build_system/targets/macos_test.dart +++ b/packages/flutter_tools/test/general.shard/build_system/targets/macos_test.dart @@ -20,6 +20,17 @@ import '../../../src/fake_process_manager.dart'; import '../../../src/fakes.dart'; import '../../../src/package_config.dart'; +/// Generate Shorebird link info arguments for iOS/macOS AOT builds. +/// The [buildPath] should be the build directory path (outputDir.parent.path). +List linkInfoArgsFor(String buildPath) => [ + '--print_class_table_link_debug_info_to=$buildPath/App.class_table.json', + '--print_class_table_link_info_to=$buildPath/App.ct.link', + '--print_field_table_link_debug_info_to=$buildPath/App.field_table.json', + '--print_field_table_link_info_to=$buildPath/App.ft.link', + '--print_dispatch_table_link_debug_info_to=$buildPath/App.dispatch_table.json', + '--print_dispatch_table_link_info_to=$buildPath/App.dt.link', +]; + void main() { late Environment environment; late MemoryFileSystem fileSystem; @@ -753,6 +764,73 @@ void main() { }, ); + testUsingContext( + 'ReleaseMacOSBundleFlutterAssets updates shorebird.yaml if present', + () async { + environment.defines[kBuildMode] = 'release'; + environment.defines[kXcodeAction] = 'install'; + environment.defines[kFlavor] = 'internal'; + + // Set up engine artifacts + fileSystem + .file('bin/cache/artifacts/engine/darwin-x64/vm_isolate_snapshot.bin') + .createSync(recursive: true); + fileSystem + .file('bin/cache/artifacts/engine/darwin-x64/isolate_snapshot.bin') + .createSync(recursive: true); + + // Set up App.framework binary + fileSystem + .file(fileSystem.path.join(environment.buildDir.path, 'App.framework', 'App')) + .createSync(recursive: true); + + // Set up native_assets.json (required by MacOSBundleFlutterAssets) + environment.buildDir.childFile('native_assets.json').createSync(); + + // Set up pubspec.yaml with shorebird.yaml as an asset + fileSystem.file('pubspec.yaml') + ..createSync() + ..writeAsStringSync(''' +name: example +flutter: + assets: + - shorebird.yaml +'''); + + // Create the shorebird.yaml asset file + fileSystem.file('shorebird.yaml') + ..createSync() + ..writeAsStringSync(''' +# Some other text that should be removed +app_id: base-app-id +flavors: + internal: internal-app-id + stable: stable-app-id +'''); + + // Set up package config + writePackageConfigFiles(directory: fileSystem.currentDirectory, mainLibName: 'example'); + + await const ReleaseMacOSBundleFlutterAssets().build(environment); + + // The output is in environment.outputDir, not buildDir + final String shorebirdYamlPath = fileSystem.path.join( + environment.outputDir.path, + 'App.framework', + 'Versions', + 'A', + 'Resources', + 'flutter_assets', + 'shorebird.yaml', + ); + expect(fileSystem.file(shorebirdYamlPath).readAsStringSync(), 'app_id: internal-app-id'); + }, + overrides: { + FileSystem: () => fileSystem, + ProcessManager: () => processManager, + }, + ); + testUsingContext( 'DebugMacOSFramework creates universal binary', () async { @@ -810,11 +888,13 @@ void main() { .childFile('x86_64/App.framework.dSYM/Contents/Resources/DWARF/App') .createSync(recursive: true); + final build = environment.buildDir.path; processManager.addCommands([ FakeCommand( command: [ 'Artifact.genSnapshotArm64.TargetPlatform.darwin.release', '--deterministic', + ...linkInfoArgsFor(build), '--snapshot_kind=app-aot-assembly', '--assembly=${environment.buildDir.childFile('arm64/snapshot_assembly.S').path}', environment.buildDir.childFile('app.dill').path, @@ -824,6 +904,7 @@ void main() { command: [ 'Artifact.genSnapshotX64.TargetPlatform.darwin.release', '--deterministic', + ...linkInfoArgsFor(build), '--snapshot_kind=app-aot-assembly', '--assembly=${environment.buildDir.childFile('x86_64/snapshot_assembly.S').path}', environment.buildDir.childFile('app.dill').path, diff --git a/packages/flutter_tools/test/general.shard/build_system/targets/web_test.dart b/packages/flutter_tools/test/general.shard/build_system/targets/web_test.dart index 820679c8a50ab..de87d52606951 100644 --- a/packages/flutter_tools/test/general.shard/build_system/targets/web_test.dart +++ b/packages/flutter_tools/test/general.shard/build_system/targets/web_test.dart @@ -293,6 +293,71 @@ name: foo ); }); + group('--web-define', () { + test( + 'WebTemplatedFiles substitutes web-define variables in index.html', + () => testbed.run(() async { + environment.defines['${kWebDefinePrefix}VERSION'] = 'v1.2.3'; + environment.defines['${kWebDefinePrefix}API_URL'] = 'https://api.example.com'; + final Directory webResources = environment.projectDir.childDirectory('web'); + webResources.childFile('index.html').createSync(recursive: true); + webResources.childFile('index.html').writeAsStringSync(''' + + + + '''); + environment.buildDir.childFile('main.dart.js').createSync(); + await WebTemplatedFiles(>[]).build(environment); + + final String outputHtml = environment.outputDir.childFile('index.html').readAsStringSync(); + expect(outputHtml, contains("const version = 'v1.2.3'")); + expect(outputHtml, contains("const apiUrl = 'https://api.example.com'")); + }), + ); + + test( + 'WebTemplatedFiles substitutes web-define variables in flutter_bootstrap.js', + () => testbed.run(() async { + environment.defines['${kWebDefinePrefix}APP_VERSION'] = 'test-build-42'; + final Directory webResources = environment.projectDir.childDirectory('web'); + webResources.childFile('index.html').createSync(recursive: true); + webResources.childFile('flutter_bootstrap.js').createSync(recursive: true); + webResources.childFile('flutter_bootstrap.js').writeAsStringSync(''' +const appVersion = '{{APP_VERSION}}'; +_flutter.loader.load(); +'''); + environment.buildDir.childFile('main.dart.js').createSync(); + await WebTemplatedFiles(>[]).build(environment); + + final String outputBootstrap = environment.outputDir + .childFile('flutter_bootstrap.js') + .readAsStringSync(); + expect(outputBootstrap, contains("const appVersion = 'test-build-42'")); + }), + ); + + test( + 'WebTemplatedFiles works with no web-define variables', + () => testbed.run(() async { + final Directory webResources = environment.projectDir.childDirectory('web'); + webResources.childFile('index.html').createSync(recursive: true); + webResources.childFile('index.html').writeAsStringSync(''' + + '''); + environment.buildDir.childFile('main.dart.js').createSync(); + await WebTemplatedFiles(>[]).build(environment); + + expect( + environment.outputDir.childFile('index.html').readAsStringSync(), + contains(''), + ); + }), + ); + }); + test( 'WebReleaseBundle copies dart2js output and resource files to output directory', () => testbed.run(() async { diff --git a/packages/flutter_tools/test/general.shard/cache_test.dart b/packages/flutter_tools/test/general.shard/cache_test.dart index 4a6152961b800..f6a13ee7c5945 100644 --- a/packages/flutter_tools/test/general.shard/cache_test.dart +++ b/packages/flutter_tools/test/general.shard/cache_test.dart @@ -884,7 +884,7 @@ void main() { expect(messages, ['Downloading Web SDK...']); expect(downloads, [ - 'https://storage.googleapis.com/flutter_infra_release/flutter/hijklmnop/flutter-web-sdk.zip', + 'https://download.shorebird.dev/flutter_infra_release/flutter/hijklmnop/flutter-web-sdk.zip', ]); expect(locations, ['/bin/cache/flutter_web_sdk']); @@ -1005,7 +1005,7 @@ void main() { expect(messages, ['Downloading engine information...']); expect(downloads, [ - 'https://storage.googleapis.com/flutter_infra_release/flutter/hijklmnop/engine_stamp.json', + 'https://download.shorebird.dev/flutter_infra_release/flutter/hijklmnop/engine_stamp.json', ]); expect(locations, ['/bin/cache']); // file copy is done by the real uploader; not the fake. diff --git a/packages/flutter_tools/test/general.shard/ios/core_devices_test.dart b/packages/flutter_tools/test/general.shard/ios/core_devices_test.dart index a6f391564ce21..d7e688fd95847 100644 --- a/packages/flutter_tools/test/general.shard/ios/core_devices_test.dart +++ b/packages/flutter_tools/test/general.shard/ios/core_devices_test.dart @@ -217,6 +217,7 @@ void main() { bundleId: 'bundle-id', launchArguments: [], shutdownHooks: FakeShutdownHooks(), + mode: BuildMode.debug, ); expect(result, isTrue); @@ -267,6 +268,7 @@ void main() { bundleId: 'bundle-id', launchArguments: [], shutdownHooks: FakeShutdownHooks(), + mode: BuildMode.debug, ); expect(result, isFalse); @@ -311,6 +313,7 @@ void main() { bundleId: 'bundle-id', launchArguments: [], shutdownHooks: FakeShutdownHooks(), + mode: BuildMode.debug, ); expect(result, isFalse); @@ -361,6 +364,7 @@ void main() { bundleId: 'bundle-id', launchArguments: [], shutdownHooks: FakeShutdownHooks(), + mode: BuildMode.debug, ); expect(result, isFalse); @@ -405,6 +409,7 @@ void main() { bundleId: 'bundle-id', launchArguments: [], shutdownHooks: FakeShutdownHooks(), + mode: BuildMode.debug, ); expect(result, isFalse); @@ -451,6 +456,7 @@ void main() { bundleId: 'bundle-id', launchArguments: [], shutdownHooks: FakeShutdownHooks(), + mode: BuildMode.debug, ); expect(result, isFalse); @@ -499,6 +505,7 @@ void main() { bundleId: 'bundle-id', launchArguments: [], shutdownHooks: FakeShutdownHooks(), + mode: BuildMode.debug, ); expect(result, isFalse); @@ -3958,6 +3965,7 @@ class FakeLLDB extends Fake implements LLDB { required String deviceId, required int appProcessId, required LLDBLogForwarder lldbLogForwarder, + required BuildMode mode, }) async { attemptedToAttach = true; return attachSuccess; diff --git a/packages/flutter_tools/test/general.shard/ios/ios_device_start_nonprebuilt_test.dart b/packages/flutter_tools/test/general.shard/ios/ios_device_start_nonprebuilt_test.dart index a3a2980730a3e..d12cbb87736c7 100644 --- a/packages/flutter_tools/test/general.shard/ios/ios_device_start_nonprebuilt_test.dart +++ b/packages/flutter_tools/test/general.shard/ios/ios_device_start_nonprebuilt_test.dart @@ -1716,6 +1716,7 @@ class FakeIOSCoreDeviceLauncher extends Fake implements IOSCoreDeviceLauncher { required String bundlePath, required String bundleId, required List launchArguments, + required BuildMode mode, required ShutdownHooks shutdownHooks, }) async { return true; diff --git a/packages/flutter_tools/test/general.shard/ios/ios_device_start_prebuilt_test.dart b/packages/flutter_tools/test/general.shard/ios/ios_device_start_prebuilt_test.dart index 6f1525982aaac..0084ba8173fd8 100644 --- a/packages/flutter_tools/test/general.shard/ios/ios_device_start_prebuilt_test.dart +++ b/packages/flutter_tools/test/general.shard/ios/ios_device_start_prebuilt_test.dart @@ -1803,6 +1803,7 @@ class FakeIOSCoreDeviceLauncher extends Fake implements IOSCoreDeviceLauncher { required String bundlePath, required String bundleId, required List launchArguments, + required BuildMode mode, required ShutdownHooks shutdownHooks, }) async { launchedWithLLDB = true; diff --git a/packages/flutter_tools/test/general.shard/ios/lldb_test.dart b/packages/flutter_tools/test/general.shard/ios/lldb_test.dart index 562f2c03a39ee..971118c97d69e 100644 --- a/packages/flutter_tools/test/general.shard/ios/lldb_test.dart +++ b/packages/flutter_tools/test/general.shard/ios/lldb_test.dart @@ -10,6 +10,7 @@ import 'package:fake_async/fake_async.dart'; import 'package:flutter_tools/src/base/io.dart'; import 'package:flutter_tools/src/base/logger.dart'; import 'package:flutter_tools/src/base/process.dart'; +import 'package:flutter_tools/src/build_info.dart'; import 'package:flutter_tools/src/ios/lldb.dart'; import 'package:test/fake.dart'; @@ -42,6 +43,7 @@ void main() { deviceId: deviceId, appProcessId: appProcessId, lldbLogForwarder: FakeLLDBLogForwarder(), + mode: BuildMode.debug, ); expect(success, isFalse); expect(lldb.isRunning, isFalse); @@ -89,6 +91,7 @@ void main() { 'device select $deviceId', breakPointMatcher, 'breakpoint command add --script-type python $breakpointId', + 'script lldb.debugger.SetAsync(False)', processAttachMatcher, processResumedMatcher, ]; @@ -118,7 +121,7 @@ Target 0: (Runner) stopped. ); } if (line == processResumedMatcher) { - processResumedCompleted.complete(utf8.encode('Process $appProcessId resuming\n')); + processResumedCompleted.complete(utf8.encode('1 location added to breakpoint 1\n')); } }); @@ -126,6 +129,78 @@ Target 0: (Runner) stopped. deviceId: deviceId, appProcessId: appProcessId, lldbLogForwarder: FakeLLDBLogForwarder(), + mode: BuildMode.debug, + ); + expect(success, isTrue); + expect(lldb.isRunning, isTrue); + expect(lldb.appProcessId, appProcessId); + expect(expectedInputs, isEmpty); + expect(processManager.hasRemainingExpectations, isFalse); + expect(logger.errorText, isEmpty); + }); + + testWithoutContext('attachAndStart returns true on success for profile mode', () async { + const deviceId = '123'; + const appProcessId = 5678; + + final processAttachCompleter = Completer>(); + final processResumedCompleted = Completer>(); + + final stdoutStream = Stream>.fromFutures([ + processAttachCompleter.future, + processResumedCompleted.future, + ]); + + final stdinController = StreamController>(); + + final processCompleter = Completer(); + final lldbCommand = FakeLLDBCommand( + command: const ['lldb'], + completer: processCompleter, + stdin: io.IOSink(stdinController.sink), + stdout: stdoutStream, + stderr: const Stream.empty(), + ); + + final logger = BufferLogger.test(); + + final processManager = FakeLLDBProcessManager([lldbCommand]); + final processUtils = ProcessUtils(processManager: processManager, logger: logger); + final lldb = LLDB(logger: logger, processUtils: processUtils); + + const processAttachMatcher = 'device process attach --pid $appProcessId'; + const processResumedMatcher = 'process continue'; + final expectedInputs = ['device select $deviceId', processAttachMatcher, processResumedMatcher]; + + stdinController.stream.transform(utf8.decoder).transform(const LineSplitter()).listen(( + String line, + ) { + expectedInputs.remove(line); + if (line == processAttachMatcher) { + processAttachCompleter.complete( + utf8.encode(''' +Process 568 stopped +* thread #1, stop reason = signal SIGSTOP + frame #0: 0x0000000102c7b240 dyld`_dyld_start +dyld`_dyld_start: +-> 0x102c7b240 <+0>: mov x0, sp + 0x102c7b244 <+4>: and sp, x0, #0xfffffffffffffff0 + 0x102c7b248 <+8>: mov x29, #0x0 ; =0 + 0x102c7b24c <+12>: mov x30, #0x0 ; =0 +Target 0: (Runner) stopped. +'''), + ); + } + if (line == processResumedMatcher) { + processResumedCompleted.complete(utf8.encode('Process 568 resuming\n')); + } + }); + + final bool success = await lldb.attachAndStart( + deviceId: deviceId, + appProcessId: appProcessId, + lldbLogForwarder: FakeLLDBLogForwarder(), + mode: BuildMode.profile, ); expect(success, isTrue); expect(lldb.isRunning, isTrue); @@ -180,6 +255,7 @@ Target 0: (Runner) stopped. deviceId: deviceId, appProcessId: appProcessId, lldbLogForwarder: FakeLLDBLogForwarder(), + mode: BuildMode.debug, ); expect(success, isFalse); expect(lldb.isRunning, isFalse); @@ -230,6 +306,7 @@ Target 0: (Runner) stopped. deviceId: deviceId, appProcessId: appProcessId, lldbLogForwarder: FakeLLDBLogForwarder(), + mode: BuildMode.debug, ); expect(success, isFalse); expect(lldb.isRunning, isFalse); @@ -275,6 +352,7 @@ Target 0: (Runner) stopped. deviceId: deviceId, appProcessId: appProcessId, lldbLogForwarder: FakeLLDBLogForwarder(), + mode: BuildMode.debug, ); time.elapse(const Duration(minutes: 2)); time.flushMicrotasks(); @@ -328,6 +406,7 @@ Target 0: (Runner) stopped. 'device select $deviceId', breakPointMatcher, 'breakpoint command add --script-type python $breakpointId', + 'script lldb.debugger.SetAsync(False)', processAttachMatcher, processResumedMatcher, ]; @@ -357,7 +436,7 @@ Target 0: (Runner) stopped. ); } if (line == processResumedMatcher) { - processResumedCompleted.complete(utf8.encode('Process $appProcessId resuming\n')); + processResumedCompleted.complete(utf8.encode('1 location added to breakpoint 1\n')); } }); @@ -369,6 +448,7 @@ Target 0: (Runner) stopped. deviceId: deviceId, appProcessId: appProcessId, lldbLogForwarder: lldbLogForwarder, + mode: BuildMode.debug, ); logAfterAttachCompleter.complete(utf8.encode('$ignoreLog\n$expectedForwardedLog\n')); @@ -420,6 +500,7 @@ Target 0: (Runner) stopped. deviceId: deviceId, appProcessId: appProcessId, lldbLogForwarder: FakeLLDBLogForwarder(), + mode: BuildMode.debug, ), ); diff --git a/packages/flutter_tools/test/general.shard/ios/mac_test.dart b/packages/flutter_tools/test/general.shard/ios/mac_test.dart index dbfca574ee8c2..24e518521a697 100644 --- a/packages/flutter_tools/test/general.shard/ios/mac_test.dart +++ b/packages/flutter_tools/test/general.shard/ios/mac_test.dart @@ -8,6 +8,7 @@ import 'package:flutter_tools/src/artifacts.dart'; import 'package:flutter_tools/src/base/file_system.dart'; import 'package:flutter_tools/src/base/logger.dart'; import 'package:flutter_tools/src/base/process.dart'; +import 'package:flutter_tools/src/base/version.dart'; import 'package:flutter_tools/src/build_info.dart'; import 'package:flutter_tools/src/cache.dart'; import 'package:flutter_tools/src/dart/pub.dart'; @@ -16,6 +17,7 @@ import 'package:flutter_tools/src/device.dart'; import 'package:flutter_tools/src/flutter_manifest.dart'; import 'package:flutter_tools/src/ios/code_signing.dart'; import 'package:flutter_tools/src/ios/mac.dart'; +import 'package:flutter_tools/src/ios/xcodeproj.dart'; import 'package:flutter_tools/src/ios/xcresult.dart'; import 'package:flutter_tools/src/project.dart'; import 'package:test/fake.dart'; @@ -720,6 +722,93 @@ duplicate symbol '_$s29plugin_1_name23PluginNamePluginC9setDouble3key5valueySS_S ), ); }); + + group('parses unable to find simulator', () { + late FakeProcessManager processManager; + setUp(() { + processManager = FakeProcessManager.any(); + }); + + testUsingContext( + 'on Xcode 26 if simulator is arm-only', + () async { + final fakeDevice = FakeDevice(); + processManager = FakeProcessManager.list([ + FakeCommand( + command: [ + 'xcrun', + 'simctl', + 'list', + 'runtimes', + await fakeDevice.sdkNameAndVersion, + '--json', + ], + stdout: ''' +{ + "runtimes" : [ + { + "isAvailable" : true, + "version" : "26.0", + "isInternal" : false, + "buildversion" : "23A343", + "supportedArchitectures" : [ + "arm64" + ], + ... + } + ] +}''', + ), + ]); + const buildCommands = ['xcrun', 'cc', 'blah']; + final buildResult = XcodeBuildResult( + success: false, + stdout: '', + xcodeBuildExecution: XcodeBuildExecution( + buildCommands: buildCommands, + appDirectory: '/blah/blah', + environmentType: EnvironmentType.simulator, + buildSettings: buildSettings, + ), + xcResult: XCResult.test( + issues: [ + XCResultIssue.test( + message: + 'Unable to find a destination matching the provided destination specifier\n' + 'Available destinations for the "Runner" scheme:\n' + '{ platform:macOS, arch:arm64, variant:Designed for [iPad,iPhone], id:00006022-000868640E90A01E, name:My Mac }\n' + '{ platform:iOS, id:dvtdevice-DVTiPhonePlaceholder-iphoneos:placeholder, name:Any iOS Device }\n' + '{ platform:iOS Simulator, id:dvtdevice-DVTiOSDeviceSimulatorPlaceholder-iphonesimulator:placeholder, name:Any iOS Simulator Device}\n' + '{ platform:iOS Simulator, arch:x86_64, id:12345678-1234-1234-1234-123456789012, OS:18.2, name:Flutter-iPhone }', + subType: 'Error', + ), + ], + ), + ); + final fs = MemoryFileSystem.test(); + final project = FakeFlutterProject(fileSystem: fs, usesSwiftPackageManager: true); + project.ios.podfile.createSync(recursive: true); + await diagnoseXcodeBuildFailure( + buildResult, + logger: logger, + analytics: fakeAnalytics, + fileSystem: fs, + platform: FlutterDarwinPlatform.ios, + project: project, + device: fakeDevice, + ); + expect( + logger.errorText, + contains('The selected simulator is incompatible with the current build settings'), + ); + expect(processManager.hasRemainingExpectations, isFalse); + }, + overrides: { + XcodeProjectInterpreter: () => FakeXcodeProjectInterpreter(version: Version(26, 0, 0)), + ProcessManager: () => processManager, + }, + ); + }); }); group('Upgrades project.pbxproj for old asset usage', () { @@ -1000,3 +1089,40 @@ class FakeArtifacts extends Fake implements Artifacts { return frameworkPath; } } + +class FakeDevice extends Fake implements Device { + @override + String get name => 'My Mac'; + + @override + String get id => 'my-mac'; + + @override + Future get sdkNameAndVersion async => 'com.apple.CoreSimulator.SimRuntime.iOS-26-2'; +} + +class FakeXcodeProjectInterpreter extends Fake implements XcodeProjectInterpreter { + FakeXcodeProjectInterpreter({ + this.isInstalled = true, + this.version, + this.schemes = const ['Runner'], + }); + + @override + final bool isInstalled; + + @override + final Version? version; + + List schemes; + + @override + Future getInfo(String projectPath, {String? projectFilename}) async { + return XcodeProjectInfo([], [], schemes, BufferLogger.test()); + } + + @override + List xcrunCommand() { + return ['xcrun']; + } +} diff --git a/packages/flutter_tools/test/general.shard/ios/xcodeproj_test.dart b/packages/flutter_tools/test/general.shard/ios/xcodeproj_test.dart index f2cdeac7b8d93..f2f3504fd8e99 100644 --- a/packages/flutter_tools/test/general.shard/ios/xcodeproj_test.dart +++ b/packages/flutter_tools/test/general.shard/ios/xcodeproj_test.dart @@ -1188,7 +1188,7 @@ Information about project "Runner": } testUsingContext( - 'prints Warning when a plugin excludes arm64 on Xcode 26+', + 'prints Warning when a plugin or transitive dependency excludes arm64 on Xcode 26+', () async { const BuildInfo buildInfo = BuildInfo.debug; @@ -1230,22 +1230,64 @@ Information about project "Runner": Build settings for action build and target bad_plugin: EXCLUDED_ARCHS = i386 arm64 +Build settings for action build and target BadPluginPodDependency: + EXCLUDED_ARCHS = i386 arm64 + +Build settings for action build and target SecondPodDependency: + EXCLUDED_ARCHS = i386 arm64 + +Build settings for action build and target ThirdPodDependency-Sub: + EXCLUDED_ARCHS = i386 arm64 + +Build settings for action build and target FourthPodDependency: + EXCLUDED_ARCHS = i386 arm64 + Build settings for action build and target good_plugin: EXCLUDED_ARCHS = i386 ''', ), ]); - await updateGeneratedXcodeProperties(project: project, buildInfo: buildInfo); + fs.file('path/to/project/ios/Podfile.lock').writeAsStringSync(''' +PODS: + - good_plugin (1.0.0): + - Flutter + - bad_plugin (1.0.0): + - Flutter + - BadPluginPodDependency + - BadPluginPodDependency (1.0.0): + - SecondPodDependency/Sub + - SecondPodDependency/Sub (1.0.0): + - ThirdPodDependency + - ThirdPodDependency (1.0.0): + - FourthPodDependency (1.0.0): +'''); + await updateGeneratedXcodeProperties( + project: project, + buildInfo: buildInfo, + printWarnings: true, + ); expect( logger.warningText, contains( - 'The following plugin(s) are excluding the arm64 architecture, which is a requirement for Xcode 26+', + 'The following target(s) do not support arm64 architecture, which is a requirement for ' + 'Apple Silicon iOS 26+ simulators:\n' + ' - bad_plugin (Flutter plugin)\n' + ' - BadPluginPodDependency (transitive dependency of Flutter plugin bad_plugin)\n' + ' - SecondPodDependency (transitive dependency of Flutter plugin bad_plugin)\n' + ' - ThirdPodDependency-Sub (transitive dependency of Flutter plugin bad_plugin)\n' + ' - FourthPodDependency\n', ), ); - expect(logger.warningText, contains('bad_plugin')); expect(fakeProcessManager, hasNoRemainingExpectations); + + final File config = fs.file('path/to/project/ios/Flutter/Generated.xcconfig'); + expect( + config.readAsStringSync(), + contains('EXCLUDED_ARCHS[sdk=iphonesimulator*]=i386 arm64'), + ); + expect(config.readAsStringSync(), contains('EXCLUDED_ARCHS[sdk=iphoneos*]=armv7')); }, overrides: { Artifacts: () => localIosArtifacts, @@ -1257,8 +1299,9 @@ Build settings for action build and target good_plugin: Logger: () => logger, }, ); + testUsingContext( - 'succeeds when no plugins exclude arm64 on Xcode 26+', + 'does not exclude arm64 simulator when supported by all plugins', () async { const BuildInfo buildInfo = BuildInfo.debug; @@ -1301,9 +1344,14 @@ Build settings for action build and target good_plugin: ), ]); - await updateGeneratedXcodeProperties(project: project, buildInfo: buildInfo); + await updateGeneratedXcodeProperties( + project: project, + buildInfo: buildInfo, + printWarnings: true, + ); final File config = fs.file('path/to/project/ios/Flutter/Generated.xcconfig'); + expect(logger.warningText, isEmpty); expect(config.readAsStringSync(), contains('EXCLUDED_ARCHS[sdk=iphonesimulator*]=i386')); expect(config.readAsStringSync(), contains('EXCLUDED_ARCHS[sdk=iphoneos*]=armv7')); expect(fakeProcessManager, hasNoRemainingExpectations); @@ -1338,7 +1386,6 @@ Build settings for action build and target good_plugin: createFakePlugins(project, fs, ['good_plugin']); final String buildDirectory = fs.path.absolute('build', 'ios'); - final testLogger = BufferLogger.test(); fakeProcessManager.addCommands([ kWhichSysctlCommand, @@ -1365,9 +1412,16 @@ Build settings for action build and target good_plugin: ), ]); - await updateGeneratedXcodeProperties(project: project, buildInfo: buildInfo); + await updateGeneratedXcodeProperties( + project: project, + buildInfo: buildInfo, + printWarnings: true, + ); - expect(testLogger.warningText, isEmpty); + final File config = fs.file('path/to/project/ios/Flutter/Generated.xcconfig'); + expect(logger.warningText, isEmpty); + expect(config.readAsStringSync(), contains('EXCLUDED_ARCHS[sdk=iphonesimulator*]=i386')); + expect(config.readAsStringSync(), contains('EXCLUDED_ARCHS[sdk=iphoneos*]=armv7')); expect(fakeProcessManager, hasNoRemainingExpectations); }, overrides: { @@ -1427,16 +1481,24 @@ Build settings for action build and target bad_plugin: ), ]); - await updateGeneratedXcodeProperties(project: project, buildInfo: buildInfo); + await updateGeneratedXcodeProperties( + project: project, + buildInfo: buildInfo, + printWarnings: true, + ); expect( logger.warningText, contains( - 'The following plugin(s) are excluding the arm64 architecture, which is a requirement for Xcode 26+', + 'The following target(s) do not support arm64 architecture, which is a requirement for ' + 'Apple Silicon iOS 26+ simulators:', ), ); expect(logger.warningText, contains('bad_plugin')); expect(fakeProcessManager, hasNoRemainingExpectations); + final File config = fs.file('path/to/project/ios/Flutter/Generated.xcconfig'); + expect(config.readAsStringSync(), contains('EXCLUDED_ARCHS[sdk=iphonesimulator*]=i386')); + expect(config.readAsStringSync(), contains('EXCLUDED_ARCHS[sdk=iphoneos*]=armv7')); }, overrides: { Artifacts: () => localIosArtifacts, @@ -1450,8 +1512,16 @@ Build settings for action build and target bad_plugin: ); testUsingContext( - 'ignores non-plugin targets that exclude arm64', + 'does not print warning on Xcode < 26', () async { + xcodeProjectInterpreter = XcodeProjectInterpreter.test( + processManager: fakeProcessManager, + version: Version(16, 0, 0), + ); + xcode = Xcode.test( + processManager: fakeProcessManager, + xcodeProjectInterpreter: xcodeProjectInterpreter, + ); const BuildInfo buildInfo = BuildInfo.debug; final Directory projectDir = fs.directory('path/to/project')..createSync(recursive: true); @@ -1466,10 +1536,9 @@ Build settings for action build and target bad_plugin: ..createSync(recursive: true); project.ios.podManifestLock.createSync(recursive: true); - createFakePlugins(project, fs, ['good_plugin']); + createFakePlugins(project, fs, ['bad_plugin', 'good_plugin']); final String buildDirectory = fs.path.absolute('build', 'ios'); - final testLogger = BufferLogger.test(); fakeProcessManager.addCommands([ kWhichSysctlCommand, @@ -1490,8 +1559,20 @@ Build settings for action build and target bad_plugin: 'OBJROOT=$buildDirectory', ], stdout: ''' -Build settings for action build and target SomeNonPluginTarget: - EXCLUDED_ARCHS = arm64 +Build settings for action build and target bad_plugin: + EXCLUDED_ARCHS = i386 arm64 + +Build settings for action build and target BadPluginPodDependency: + EXCLUDED_ARCHS = i386 arm64 + +Build settings for action build and target SecondPodDependency: + EXCLUDED_ARCHS = i386 arm64 + +Build settings for action build and target ThirdPodDependency-Sub: + EXCLUDED_ARCHS = i386 arm64 + +Build settings for action build and target FourthPodDependency: + EXCLUDED_ARCHS = i386 arm64 Build settings for action build and target good_plugin: EXCLUDED_ARCHS = i386 @@ -1499,49 +1580,35 @@ Build settings for action build and target good_plugin: ), ]); - await updateGeneratedXcodeProperties(project: project, buildInfo: buildInfo); - - expect(testLogger.warningText, isEmpty); - expect(fakeProcessManager, hasNoRemainingExpectations); - }, - overrides: { - Artifacts: () => localIosArtifacts, - Platform: () => macOS, - FileSystem: () => fs, - ProcessManager: () => fakeProcessManager, - XcodeProjectInterpreter: () => xcodeProjectInterpreter, - Xcode: () => xcode, - Logger: () => BufferLogger.test(), - }, - ); - - testUsingContext( - 'succeeds and skips check on Xcode 16 even if a plugin excludes arm64', - () async { - xcodeProjectInterpreter = XcodeProjectInterpreter.test( - processManager: fakeProcessManager, - version: Version(16, 0, 0), - ); - xcode = Xcode.test( - processManager: fakeProcessManager, - xcodeProjectInterpreter: xcodeProjectInterpreter, - ); - - const BuildInfo buildInfo = BuildInfo.debug; - final FlutterProject project = FlutterProject.fromDirectoryTest( - fs.directory('path/to/project'), + fs.file('path/to/project/ios/Podfile.lock').writeAsStringSync(''' +PODS: + - good_plugin (1.0.0): + - Flutter + - bad_plugin (1.0.0): + - Flutter + - BadPluginPodDependency + - BadPluginPodDependency (1.0.0): + - SecondPodDependency/Sub + - SecondPodDependency/Sub (1.0.0): + - ThirdPodDependency + - ThirdPodDependency (1.0.0): + - FourthPodDependency (1.0.0): +'''); + await updateGeneratedXcodeProperties( + project: project, + buildInfo: buildInfo, + printWarnings: true, ); - project.ios.hostAppRoot - .childDirectory('Pods') - .childDirectory('Pods.xcodeproj') - .createSync(recursive: true); - await updateGeneratedXcodeProperties(project: project, buildInfo: buildInfo); + expect(logger.warningText, isEmpty); + expect(fakeProcessManager, hasNoRemainingExpectations); final File config = fs.file('path/to/project/ios/Flutter/Generated.xcconfig'); - expect(config.readAsStringSync(), contains('EXCLUDED_ARCHS[sdk=iphonesimulator*]=i386')); + expect( + config.readAsStringSync(), + contains('EXCLUDED_ARCHS[sdk=iphonesimulator*]=i386 arm64'), + ); expect(config.readAsStringSync(), contains('EXCLUDED_ARCHS[sdk=iphoneos*]=armv7')); - expect(fakeProcessManager, hasNoRemainingExpectations); }, overrides: { Artifacts: () => localIosArtifacts, diff --git a/packages/flutter_tools/test/general.shard/macos/cocoapod_utils_test.dart b/packages/flutter_tools/test/general.shard/macos/cocoapod_utils_test.dart index b31b284e0ad5c..2a720e0260088 100644 --- a/packages/flutter_tools/test/general.shard/macos/cocoapod_utils_test.dart +++ b/packages/flutter_tools/test/general.shard/macos/cocoapod_utils_test.dart @@ -517,6 +517,10 @@ class FakeMacOSProject extends Fake implements MacOSProject { File get flutterPluginSwiftPackageManifest => flutterPluginSwiftPackageDirectory.childFile('Package.swift'); + @override + Directory get flutterFrameworkSwiftPackageDirectory => + relativeSwiftPackagesDirectory.childDirectory('FlutterFramework'); + @override bool usesSwiftPackageManager = false; @@ -566,6 +570,10 @@ class FakeIosProject extends Fake implements IosProject { File get flutterPluginSwiftPackageManifest => flutterPluginSwiftPackageDirectory.childFile('Package.swift'); + @override + Directory get flutterFrameworkSwiftPackageDirectory => + relativeSwiftPackagesDirectory.childDirectory('FlutterFramework'); + @override bool usesSwiftPackageManager = false; diff --git a/packages/flutter_tools/test/general.shard/macos/swift_package_manager_test.dart b/packages/flutter_tools/test/general.shard/macos/swift_package_manager_test.dart index ac7838a5c47f1..080cd339589b7 100644 --- a/packages/flutter_tools/test/general.shard/macos/swift_package_manager_test.dart +++ b/packages/flutter_tools/test/general.shard/macos/swift_package_manager_test.dart @@ -249,6 +249,66 @@ let package = Package( ) ] ) +'''); + }); + + testWithoutContext('generates FlutterFramework', () async { + final fs = MemoryFileSystem(); + final project = FakeXcodeProject(platform: platform.name, fileSystem: fs); + project.xcodeProjectInfoFile.createSync(recursive: true); + project.xcodeProjectInfoFile.writeAsStringSync(''' +' 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; };'; +'''); + + final spm = SwiftPackageManager( + fileSystem: fs, + templateRenderer: const MustacheTemplateRenderer(), + ); + await spm.generatePluginsSwiftPackage([], platform, project); + + final supportedPlatform = platform == FlutterDarwinPlatform.ios + ? '.iOS("13.0")' + : '.macOS("10.15")'; + final File manifest = project.flutterFrameworkSwiftPackageDirectory.childFile( + 'Package.swift', + ); + expect(manifest.existsSync(), isTrue); + expect(manifest.readAsStringSync(), ''' +// swift-tools-version: 5.9 +// The swift-tools-version declares the minimum version of Swift required to build this package. +// +// Generated file. Do not edit. +// + +import PackageDescription + +let package = Package( + name: "FlutterFramework", + platforms: [ + $supportedPlatform + ], + products: [ + .library(name: "FlutterFramework", targets: ["FlutterFramework"]) + ], + dependencies: [ +$_doubleIndent + ], + targets: [ + .target( + name: "FlutterFramework" + ) + ] +) +'''); + final File soureFile = project.flutterFrameworkSwiftPackageDirectory + .childDirectory('Sources') + .childDirectory('FlutterFramework') + .childFile('FlutterFramework.swift'); + expect(soureFile.existsSync(), isTrue); + expect(soureFile.readAsStringSync(), ''' +// +// Generated file. Do not edit. +// '''); }); }); @@ -373,6 +433,10 @@ class FakeXcodeProject extends Fake implements IosProject { File get flutterPluginSwiftPackageManifest => flutterPluginSwiftPackageDirectory.childFile('Package.swift'); + @override + Directory get flutterFrameworkSwiftPackageDirectory => + relativeSwiftPackagesDirectory.childDirectory('FlutterFramework'); + @override bool get flutterPluginSwiftPackageInProjectSettings { return xcodeProjectInfoFile.existsSync() && diff --git a/packages/flutter_tools/test/general.shard/resident_web_runner_test.dart b/packages/flutter_tools/test/general.shard/resident_web_runner_test.dart index 2a5b67c71571c..618abe6b6c039 100644 --- a/packages/flutter_tools/test/general.shard/resident_web_runner_test.dart +++ b/packages/flutter_tools/test/general.shard/resident_web_runner_test.dart @@ -270,6 +270,24 @@ name: my_app }, ); + testUsingContext( + 'Does not crash if the application exits during DDS startup', + () async { + // Regression test for https://github.com/flutter/flutter/issues/178151 + final ResidentRunner residentWebRunner = setUpResidentRunner(flutterDevice); + fakeVmServiceHost = FakeVmServiceHost(requests: kAttachExpectations.toList()); + setupMocks(); + webDevFS.exception = DartDevelopmentServiceException.failedToStart(); + + await expectLater(residentWebRunner.run(), throwsToolExit()); + }, + overrides: { + FileSystem: () => fileSystem, + ProcessManager: () => processManager, + Pub: ThrowingPub.new, + }, + ); + testUsingContext( 'WebRunner copies compiled app.dill to cache during startup', () async { diff --git a/packages/flutter_tools/test/general.shard/run_hot_test.dart b/packages/flutter_tools/test/general.shard/run_hot_test.dart index 32eec1532444a..7cfb149b5eccc 100644 --- a/packages/flutter_tools/test/general.shard/run_hot_test.dart +++ b/packages/flutter_tools/test/general.shard/run_hot_test.dart @@ -105,6 +105,46 @@ void main() { }, ); }); + + group('multiple target devices', () { + late List<_FakeHotCompatibleFlutterDevice> flutterDevices; + late MemoryFileSystem fileSystem; + + setUp(() { + flutterDevices = [ + _FakeHotCompatibleFlutterDevice(FakeDevice()), + _FakeHotCompatibleFlutterDevice(FakeDevice()), + ]; + fileSystem = MemoryFileSystem.test(); + }); + + testUsingContext( + 'regression test for https://github.com/flutter/flutter/issues/179857', + () async { + final runner = HotRunner( + flutterDevices, + target: 'main.dart', + debuggingOptions: DebuggingOptions.disabled(BuildInfo.debug), + analytics: _FakeAnalytics(), + ); + + await runner.run(); + await runner.cleanupAfterSignal(); + + // Providing multiple Flutter devices should result in the target platform being set to + // 'multiple', which we use to report analytics. + expect(runner.targetPlatformName, 'multiple'); + for (final flutterDevice in flutterDevices) { + expect(flutterDevice.wasExited, true); + expect((flutterDevice.device.dds as FakeDartDevelopmentService).wasShutdown, true); + } + }, + overrides: { + FileSystem: () => fileSystem, + ProcessManager: FakeProcessManager.empty, + }, + ); + }); } class _FakeAnalytics extends Fake implements Analytics { diff --git a/packages/flutter_tools/test/general.shard/shorebird/shorebird_yaml_test.dart b/packages/flutter_tools/test/general.shard/shorebird/shorebird_yaml_test.dart new file mode 100644 index 0000000000000..d0e3950538fb1 --- /dev/null +++ b/packages/flutter_tools/test/general.shard/shorebird/shorebird_yaml_test.dart @@ -0,0 +1,136 @@ +// Copyright 2024 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:io'; + +import 'package:flutter_tools/src/shorebird/shorebird_yaml.dart'; +import 'package:test/test.dart'; +import 'package:yaml/yaml.dart'; + +void main() { + group('ShorebirdYaml', () { + test('yaml ignores comments', () { + const String yamlContents = ''' +# This file is used to configure the Shorebird updater used by your app. +app_id: 6160a7d8-cc18-4928-1233-05b51c0bb02c + +# auto_update controls if Shorebird should automatically update in the background on launch. +auto_update: false +'''; + final YamlDocument input = loadYamlDocument(yamlContents); + final YamlMap yamlMap = input.contents as YamlMap; + final Map compiled = compileShorebirdYaml( + yamlMap, + flavor: null, + environment: {}, + ); + expect(compiled, { + 'app_id': '6160a7d8-cc18-4928-1233-05b51c0bb02c', + 'auto_update': false, + }); + }); + test('flavors', () { + // These are invalid app_ids but make for easy testing. + const String yamlContents = ''' +app_id: 1-a +auto_update: false +flavors: + foo: 2-a + bar: 3-a +'''; + final YamlDocument input = loadYamlDocument(yamlContents); + final YamlMap yamlMap = input.contents as YamlMap; + expect(appIdForFlavor(yamlMap, flavor: null), '1-a'); + expect(appIdForFlavor(yamlMap, flavor: 'foo'), '2-a'); + expect(appIdForFlavor(yamlMap, flavor: 'bar'), '3-a'); + expect(() => appIdForFlavor(yamlMap, flavor: 'unknown'), throwsException); + }); + test('all values', () { + // These are invalid app_ids but make for easy testing. + const String yamlContents = ''' +app_id: 1-a +auto_update: false +flavors: + foo: 2-a + bar: 3-a +base_url: https://example.com +patch_verification: strict +'''; + final YamlDocument input = loadYamlDocument(yamlContents); + final YamlMap yamlMap = input.contents as YamlMap; + final Map compiled1 = compileShorebirdYaml( + yamlMap, + flavor: null, + environment: {}, + ); + expect(compiled1, { + 'app_id': '1-a', + 'auto_update': false, + 'base_url': 'https://example.com', + 'patch_verification': 'strict', + }); + final Map compiled2 = compileShorebirdYaml( + yamlMap, + flavor: 'foo', + environment: {'SHOREBIRD_PUBLIC_KEY': '4-a'}, + ); + expect(compiled2, { + 'app_id': '2-a', + 'auto_update': false, + 'base_url': 'https://example.com', + 'patch_verification': 'strict', + 'patch_public_key': '4-a', + }); + }); + test('module_version from environment', () { + const String yamlContents = ''' +app_id: 1-a +'''; + final YamlDocument input = loadYamlDocument(yamlContents); + final YamlMap yamlMap = input.contents as YamlMap; + + // Without env var, module_version is absent. + final Map withoutEnv = compileShorebirdYaml( + yamlMap, + flavor: null, + environment: {}, + ); + expect(withoutEnv.containsKey('module_version'), isFalse); + + // With env var, module_version is included. + final Map withEnv = compileShorebirdYaml( + yamlMap, + flavor: null, + environment: {'SHOREBIRD_MODULE_VERSION': 'abc1234'}, + ); + expect(withEnv['module_version'], 'abc1234'); + }); + test('edit in place', () { + const String yamlContents = ''' +app_id: 1-a +auto_update: false +flavors: + foo: 2-a + bar: 3-a +base_url: https://example.com +'''; + // Make a temporary file to test editing in place. + final Directory tempDir = Directory.systemTemp.createTempSync('shorebird_yaml_test.'); + final File tempFile = File('${tempDir.path}/shorebird.yaml'); + tempFile.writeAsStringSync(yamlContents); + updateShorebirdYaml( + 'foo', + tempFile.path, + environment: {'SHOREBIRD_PUBLIC_KEY': '4-a'}, + ); + final String updatedContents = tempFile.readAsStringSync(); + // Order is not guaranteed, so parse as YAML to compare. + final YamlDocument updated = loadYamlDocument(updatedContents); + final YamlMap yamlMap = updated.contents as YamlMap; + expect(yamlMap['app_id'], '2-a'); + expect(yamlMap['auto_update'], false); + expect(yamlMap['base_url'], 'https://example.com'); + }); + }); +} diff --git a/packages/flutter_tools/test/general.shard/version_test.dart b/packages/flutter_tools/test/general.shard/version_test.dart index 1d8360c14866c..90681d938b5b0 100644 --- a/packages/flutter_tools/test/general.shard/version_test.dart +++ b/packages/flutter_tools/test/general.shard/version_test.dart @@ -67,6 +67,20 @@ void main() { required int commitsBetweenRefs, }) { return [ + // Shorebird release branch check (returns empty to fall through to + // regular version lookup). + FakeCommand( + command: [ + 'git', + 'for-each-ref', + '--contains', + headRef, + '--format', + '%(refname:short)', + 'refs/remotes/origin/flutter_release/*', + ], + stdout: '', + ), FakeCommand( command: const [ 'git', @@ -1524,6 +1538,123 @@ void main() { expect(processManager, hasNoRemainingExpectations); }, overrides: {Git: () => git}); + testUsingContext('determine resolves version from shorebird flutter_release branch', () { + processManager.addCommands([ + const FakeCommand( + command: ['git', 'tag', '--points-at', 'HEAD'], + // No tag at HEAD. + ), + const FakeCommand( + command: [ + 'git', + 'for-each-ref', + '--contains', + 'HEAD', + '--format', + '%(refname:short)', + 'refs/remotes/origin/flutter_release/*', + ], + stdout: 'origin/flutter_release/3.41.4', + ), + ]); + final platform = FakePlatform(); + + final GitTagVersion gitTagVersion = GitTagVersion.determine( + platform, + git: git, + workingDirectory: '.', + ); + expect(gitTagVersion.frameworkVersionFor('abcd1234'), '3.41.4'); + expect(processManager, hasNoRemainingExpectations); + }); + + testUsingContext('determine skips non-stable shorebird flutter_release branches', () { + const headRevision = 'abcd1234'; + processManager.addCommands([ + const FakeCommand( + command: ['git', 'tag', '--points-at', 'HEAD'], + // No tag at HEAD. + ), + // Shorebird release branch check returns only a non-stable branch. + const FakeCommand( + command: [ + 'git', + 'for-each-ref', + '--contains', + 'HEAD', + '--format', + '%(refname:short)', + 'refs/remotes/origin/flutter_release/*', + ], + stdout: 'origin/flutter_release/3.41.4-rc2', + ), + // Falls through to tag-based fallback. + const FakeCommand( + command: [ + 'git', + 'for-each-ref', + '--sort=-v:refname', + '--count=1', + '--format=%(refname:short)', + 'refs/tags/[0-9]*.*.*', + ], + stdout: '3.41.4', + ), + const FakeCommand( + command: ['git', 'merge-base', 'HEAD', '3.41.4'], + stdout: headRevision, + ), + const FakeCommand( + command: ['git', 'rev-list', '--count', '$headRevision..HEAD'], + stdout: '48', + ), + ]); + final platform = FakePlatform(); + + final GitTagVersion gitTagVersion = GitTagVersion.determine( + platform, + git: git, + workingDirectory: '.', + ); + // Should NOT be 0.0.0-unknown; the tag fallback should produce a valid version. + expect(gitTagVersion.frameworkVersionFor(headRevision), '3.41.5-0.0.pre-48'); + expect(processManager, hasNoRemainingExpectations); + }); + + testUsingContext( + 'determine picks stable branch over rc branch from shorebird flutter_release', + () { + processManager.addCommands([ + const FakeCommand( + command: ['git', 'tag', '--points-at', 'HEAD'], + // No tag at HEAD. + ), + const FakeCommand( + command: [ + 'git', + 'for-each-ref', + '--contains', + 'HEAD', + '--format', + '%(refname:short)', + 'refs/remotes/origin/flutter_release/*', + ], + // Both stable and rc branches contain this commit. + stdout: 'origin/flutter_release/3.41.4\norigin/flutter_release/3.41.4-rc2', + ), + ]); + final platform = FakePlatform(); + + final GitTagVersion gitTagVersion = GitTagVersion.determine( + platform, + git: git, + workingDirectory: '.', + ); + expect(gitTagVersion.frameworkVersionFor('abcd1234'), '3.41.4'); + expect(processManager, hasNoRemainingExpectations); + }, + ); + group('$FlutterEngineStampFromFile', () { late FileSystem fs; const flutterRoot = '/path/to/flutter'; diff --git a/packages/flutter_tools/test/general.shard/web/devfs_web_ddc_modules_test.dart b/packages/flutter_tools/test/general.shard/web/devfs_web_ddc_modules_test.dart index 3a0a1184d2120..682792b4f2a9b 100644 --- a/packages/flutter_tools/test/general.shard/web/devfs_web_ddc_modules_test.dart +++ b/packages/flutter_tools/test/general.shard/web/devfs_web_ddc_modules_test.dart @@ -81,6 +81,7 @@ void main() { webRenderer: WebRendererMode.canvaskit, useLocalCanvasKit: false, fileSystem: globals.fs, + logger: logger, ); releaseAssetServer = ReleaseAssetServer( globals.fs.file('main.dart').uri, @@ -329,6 +330,7 @@ void main() { webRenderer: WebRendererMode.canvaskit, useLocalCanvasKit: false, fileSystem: globals.fs, + logger: logger, ); expect(webAssetServer.basePath, 'foo/bar'); @@ -350,6 +352,7 @@ void main() { webRenderer: WebRendererMode.canvaskit, useLocalCanvasKit: false, fileSystem: globals.fs, + logger: logger, ); // Defaults to "/" when there's no base element. @@ -373,6 +376,7 @@ void main() { webRenderer: WebRendererMode.canvaskit, useLocalCanvasKit: false, fileSystem: globals.fs, + logger: logger, ), throwsToolExit(), ); @@ -395,6 +399,7 @@ void main() { webRenderer: WebRendererMode.canvaskit, useLocalCanvasKit: false, fileSystem: globals.fs, + logger: logger, ), throwsToolExit(), ); @@ -1216,6 +1221,7 @@ void main() { webRenderer: WebRendererMode.canvaskit, useLocalCanvasKit: false, fileSystem: globals.fs, + logger: logger, ); expect(await webAssetServer.metadataContents('foo/main_module.ddc_merged_metadata'), null); diff --git a/packages/flutter_tools/test/general.shard/web/devfs_web_test.dart b/packages/flutter_tools/test/general.shard/web/devfs_web_test.dart index 8184418e5ab0a..8dcbdfee4daa7 100644 --- a/packages/flutter_tools/test/general.shard/web/devfs_web_test.dart +++ b/packages/flutter_tools/test/general.shard/web/devfs_web_test.dart @@ -80,6 +80,7 @@ void main() { webRenderer: WebRendererMode.canvaskit, useLocalCanvasKit: false, fileSystem: globals.fs, + logger: logger, ); releaseAssetServer = ReleaseAssetServer( globals.fs.file('main.dart').uri, @@ -352,6 +353,7 @@ void main() { webRenderer: WebRendererMode.canvaskit, useLocalCanvasKit: false, fileSystem: globals.fs, + logger: logger, ); expect(webAssetServer.basePath, 'foo/bar'); @@ -376,6 +378,7 @@ void main() { webRenderer: WebRendererMode.canvaskit, useLocalCanvasKit: false, fileSystem: globals.fs, + logger: logger, ); // Defaults to "/" when there's no base element. @@ -402,6 +405,7 @@ void main() { webRenderer: WebRendererMode.canvaskit, useLocalCanvasKit: false, fileSystem: globals.fs, + logger: logger, ), throwsToolExit(), ); @@ -427,6 +431,7 @@ void main() { webRenderer: WebRendererMode.canvaskit, useLocalCanvasKit: false, fileSystem: globals.fs, + logger: logger, ), throwsToolExit(), ); @@ -500,6 +505,7 @@ void main() { webRenderer: WebRendererMode.canvaskit, useLocalCanvasKit: true, fileSystem: globals.fs, + logger: logger, ); final Response response = await webAssetServer.handleRequest( @@ -1472,6 +1478,7 @@ void main() { webRenderer: WebRendererMode.canvaskit, useLocalCanvasKit: false, fileSystem: globals.fs, + logger: logger, ); expect(await webAssetServer.metadataContents('foo/main_module.ddc_merged_metadata'), null); @@ -1538,7 +1545,7 @@ void main() { ); test( - 'WebAssetServer serves index.html with web-define variables', + 'WebAssetServer serves index.html without user defined web-define variables', () => testbed.run(() async { // Simple test case with no custom variables - should work like before globals.fs.file( @@ -1561,6 +1568,7 @@ void main() { webRenderer: WebRendererMode.canvaskit, useLocalCanvasKit: false, fileSystem: globals.fs, + logger: logger, ); final Response response = await webAssetServer.handleRequest( @@ -1571,7 +1579,7 @@ void main() { ); test( - 'WebAssetServer throws error for missing web-define variables in index.html', + 'WebAssetServer warns for missing user defined web-define variables in index.html', () => testbed.run(() async { const htmlContent = ''' @@ -1612,11 +1620,78 @@ void main() { useLocalCanvasKit: false, fileSystem: globals.fs, webDefines: {}, // Empty webDefines + logger: logger, ); + final Response response = await webAssetServer.handleRequest( + Request('GET', Uri.parse('http://foobar/')), + ); + + expect(response.statusCode, HttpStatus.ok); + // Verify the placeholder is preserved + expect(await response.readAsString(), contains("const apiUrl = '{{MISSING_VAR}}';")); + expect(logger.warningText, contains('Missing web-define variable: MISSING_VAR')); + }), + ); + + test( + 'WebAssetServer logs warning for multiple missing web-define variables in index.html', + () => testbed.run(() async { + const htmlContent = ''' + + + + Test + + + + + +'''; + + globals.fs.currentDirectory.childDirectory('web').childFile('index.html') + ..createSync(recursive: true) + ..writeAsStringSync(htmlContent); + + globals.fs.file( + globals.fs.path.join( + globals.artifacts!.getHostArtifact(HostArtifact.flutterJsDirectory).path, + 'flutter.js', + ), + ) + ..createSync(recursive: true) + ..writeAsStringSync('flutter.js content'); + + final webAssetServer = WebAssetServer( + FakeHttpServer(), + PackageConfig.empty, + InternetAddress.anyIPv4, + {}, + {}, + usesDdcModuleSystem, + canaryFeatures, + webRenderer: WebRendererMode.canvaskit, + useLocalCanvasKit: false, + fileSystem: globals.fs, + webDefines: {}, // Empty webDefines + logger: logger, + ); + + final Response response = await webAssetServer.handleRequest( + Request('GET', Uri.parse('http://foobar/')), + ); + + expect(response.statusCode, HttpStatus.ok); + // Verify the placeholders are preserved + final String responseBody = await response.readAsString(); + expect(responseBody, contains("const apiUrl = '{{MISSING_VAR_1}}';")); + expect(responseBody, contains("const apiKey = '{{MISSING_VAR_2}}';")); expect( - () async => webAssetServer.handleRequest(Request('GET', Uri.parse('http://foobar/'))), - throwsToolExit(message: 'Missing web-define variable: MISSING_VAR'), + logger.warningText, + contains('Missing web-define variables: MISSING_VAR_1, MISSING_VAR_2'), ); }), ); @@ -1657,6 +1732,7 @@ const config = { useLocalCanvasKit: false, fileSystem: globals.fs, webDefines: {'API_URL': 'https://test.api.com', 'DEBUG_MODE': 'true'}, + logger: logger, ); final Response response = await webAssetServer.handleRequest( diff --git a/packages/flutter_tools/test/general.shard/web_template_test.dart b/packages/flutter_tools/test/general.shard/web_template_test.dart index a2b03907321fd..63b61a3456179 100644 --- a/packages/flutter_tools/test/general.shard/web_template_test.dart +++ b/packages/flutter_tools/test/general.shard/web_template_test.dart @@ -4,9 +4,12 @@ import 'package:file/file.dart'; import 'package:file/memory.dart'; + +import 'package:flutter_tools/src/base/logger.dart'; import 'package:flutter_tools/src/web_template.dart'; import '../src/common.dart'; +import '../src/context.dart'; const htmlSample1 = ''' @@ -285,6 +288,7 @@ String htmlSampleStaticAssetsUrlReplaced({required String staticAssetsUrl}) => void main() { final fs = MemoryFileSystem(); + final logger = BufferLogger.test(); final File flutterJs = fs.file('flutter.js'); flutterJs.writeAsStringSync('(flutter.js content)'); @@ -316,6 +320,7 @@ void main() { baseHref: '/foo/333/', serviceWorkerVersion: 'v123xyz', flutterJsFile: flutterJs, + logger: logger, ), htmlSample2Replaced(baseHref: '/foo/333/', serviceWorkerVersion: 'v123xyz'), ); @@ -328,6 +333,7 @@ void main() { baseHref: '/foo/333/', serviceWorkerVersion: 'v123xyz', flutterJsFile: flutterJs, + logger: logger, ), htmlSample2Replaced(baseHref: '/foo/333/', serviceWorkerVersion: 'v123xyz'), ); @@ -343,6 +349,7 @@ void main() { serviceWorkerVersion: '(service worker version)', flutterJsFile: flutterJs, buildConfig: '(build config)', + logger: logger, ), htmlSampleInlineFlutterJsBootstrapOutput, ); @@ -359,6 +366,7 @@ void main() { flutterJsFile: flutterJs, buildConfig: '(build config)', flutterBootstrapJs: '(flutter bootstrap script)', + logger: logger, ), htmlSampleFullFlutterBootstrapReplacementOutput, ); @@ -374,6 +382,7 @@ void main() { serviceWorkerVersion: 'v123xyz', flutterJsFile: flutterJs, staticAssetsUrl: expectedStaticAssetsUrl, + logger: logger, ), htmlSampleStaticAssetsUrlReplaced(staticAssetsUrl: expectedStaticAssetsUrl), ); @@ -387,6 +396,7 @@ void main() { baseHref: '/foo/333/', serviceWorkerVersion: 'v123xyz', flutterJsFile: flutterJs, + logger: logger, ); // The parsed base href should be updated after substitutions. expect(WebTemplate.baseHref(substituted), 'foo/333'); @@ -452,6 +462,7 @@ void main() { 'ENV': 'production', 'DEBUG_MODE': 'false', }, + logger: logger, ); expect(result, contains("apiUrl: 'https://api.example.com'")); @@ -459,7 +470,7 @@ void main() { expect(result, contains('debugMode: false')); }); - test('throws ToolExit when web-define variable is missing', () { + testUsingContext('logs warning when user defined web-define variable is missing', () { const htmlWithMissingVar = ''' @@ -475,18 +486,20 @@ void main() { '''; const indexHtml = WebTemplate(htmlWithMissingVar); - expect( - () => indexHtml.withSubstitutions( - baseHref: '/', - serviceWorkerVersion: null, - flutterJsFile: flutterJs, - webDefines: {}, // Missing API_URL - ), - throwsToolExit(message: 'Missing web-define variable: API_URL'), + final String result = indexHtml.withSubstitutions( + baseHref: '/', + serviceWorkerVersion: null, + flutterJsFile: flutterJs, + webDefines: {}, // Missing API_URL + logger: testLogger, ); + + expect(testLogger.warningText, contains('Missing web-define variable: API_URL')); + // Verify the placeholder is preserved + expect(result, contains("const apiUrl = '{{API_URL}}';")); }); - test('throws ToolExit with multiple missing variables', () { + testUsingContext('logs warning with multiple missing user defined variables', () { const htmlWithMultipleMissingVars = ''' @@ -506,19 +519,23 @@ void main() { '''; const indexHtml = WebTemplate(htmlWithMultipleMissingVars); - expect( - () => indexHtml.withSubstitutions( - baseHref: '/', - serviceWorkerVersion: null, - flutterJsFile: flutterJs, - webDefines: {'API_URL': 'test'}, // Missing ENV, VERSION - ), - throwsToolExit(message: 'Missing web-define variable'), + final String result = indexHtml.withSubstitutions( + baseHref: '/', + serviceWorkerVersion: null, + flutterJsFile: flutterJs, + webDefines: {'API_URL': 'test'}, // Missing ENV, VERSION + logger: testLogger, ); + + expect(testLogger.warningText, contains('Missing web-define variables: ENV, VERSION')); + expect(result, contains("env: '{{ENV}}'")); + expect(result, contains("version: '{{VERSION}}'")); }); - test('ignores Flutter built-in variables when validating web-define variables', () { - const htmlWithBuiltInVars = ''' + testUsingContext( + 'ignores Flutter built-in variables and logs warning for missing user variables', + () { + const htmlWithBuiltInVars = ''' @@ -534,18 +551,20 @@ void main() { '''; - const indexHtml = WebTemplate(htmlWithBuiltInVars); - expect( - () => indexHtml.withSubstitutions( + const indexHtml = WebTemplate(htmlWithBuiltInVars); + final String result = indexHtml.withSubstitutions( baseHref: '/', serviceWorkerVersion: null, flutterJsFile: flutterJs, buildConfig: 'test config', webDefines: {}, // Missing CUSTOM_VAR but built-in vars should be ignored - ), - throwsToolExit(message: 'Missing web-define variable: CUSTOM_VAR'), - ); - }); + logger: testLogger, + ); + + expect(testLogger.warningText, contains('Missing web-define variable: CUSTOM_VAR')); + expect(result, contains("const customVar = '{{CUSTOM_VAR}}';")); + }, + ); test('allows empty web-define variables', () { const htmlWithEmptyVar = ''' @@ -568,6 +587,7 @@ void main() { serviceWorkerVersion: null, flutterJsFile: flutterJs, webDefines: {'EMPTY_VAR': ''}, + logger: logger, ); expect(result, contains("const value = '';")); diff --git a/packages/flutter_tools/test/integration.shard/forbidden_imports_test.dart b/packages/flutter_tools/test/integration.shard/forbidden_imports_test.dart index 8d434b804e13a..fc6ef010b74a5 100644 --- a/packages/flutter_tools/test/integration.shard/forbidden_imports_test.dart +++ b/packages/flutter_tools/test/integration.shard/forbidden_imports_test.dart @@ -162,23 +162,6 @@ void main() { final allowedPath = [ fileSystem.path.join(flutterTools, 'lib', 'src', 'isolated', 'web_compilation_delegate.dart'), fileSystem.path.join(flutterTools, 'test', 'general.shard', 'platform_plugins_test.dart'), - fileSystem.path.join( - flutterTools, - 'test', - 'widget_preview_scaffold.shard', - 'widget_preview_scaffold', - 'test', - 'filter_by_selected_file_test.dart', - ), - fileSystem.path.join( - flutterTools, - 'test', - 'widget_preview_scaffold.shard', - 'widget_preview_scaffold', - 'lib', - 'src', - 'widget_preview_scaffold_controller.dart', - ), ]; for (final dirName in ['lib', 'bin', 'test']) { final Iterable files = fileSystem diff --git a/packages/flutter_tools/test/integration.shard/swift_package_manager_create_plugin_test.dart b/packages/flutter_tools/test/integration.shard/swift_package_manager_create_plugin_test.dart index 470148961ef9c..668195926a43f 100644 --- a/packages/flutter_tools/test/integration.shard/swift_package_manager_create_plugin_test.dart +++ b/packages/flutter_tools/test/integration.shard/swift_package_manager_create_plugin_test.dart @@ -141,6 +141,24 @@ void main() { expect(podspec.readAsStringSync(), contains('Sources')); expect(podspec.readAsStringSync().contains('Classes'), isFalse); + // Verify that a plugin having a dependency on the FlutterFramework package + // builds successfully. + final File manifest = fileSystem + .directory(createdSwiftPackagePlugin.swiftPackagePlatformPath) + .childFile('Package.swift'); + expect(manifest.existsSync(), isTrue); + const packageDependency = + '.package(name: "FlutterFramework", path: "../FlutterFramework")'; + const targetDependency = + '.product(name: "FlutterFramework", package: "FlutterFramework")'; + final String manifestContent = manifest + .readAsStringSync() + .replaceFirst('dependencies: [],', 'dependencies: [$packageDependency],') + .replaceFirst('dependencies: [],', 'dependencies: [$targetDependency],'); + expect(manifestContent.contains(packageDependency), isTrue); + expect(manifestContent.contains(targetDependency), isTrue); + manifest.writeAsStringSync(manifestContent); + await SwiftPackageManagerUtils.buildApp( flutterBin, appDirectoryPath, diff --git a/packages/flutter_tools/test/widget_preview_scaffold.shard/.gitignore b/packages/flutter_tools/test/widget_preview_scaffold.shard/.gitignore deleted file mode 100644 index 6375cfc1e58a5..0000000000000 --- a/packages/flutter_tools/test/widget_preview_scaffold.shard/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -# The generated web assets aren't needed for widget_preview_scaffold widget tests -/widget_preview_scaffold/web/ \ No newline at end of file diff --git a/packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/.gitignore b/packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/.gitignore deleted file mode 100644 index c08861603ad68..0000000000000 --- a/packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/.gitignore +++ /dev/null @@ -1,44 +0,0 @@ -# Miscellaneous -*.class -*.log -*.pyc -*.swp -.DS_Store -.atom/ -.build/ -.buildlog/ -.history -.svn/ -.swiftpm/ -migrate_working_dir/ - -# IntelliJ related -*.iml -*.ipr -*.iws -.idea/ - -# The .vscode folder contains launch configuration and tasks you configure in -# VS Code which you may wish to be included in version control, so this line -# is commented out by default. -#.vscode/ - -# Flutter/Dart/Pub related -**/doc/api/ -**/ios/Flutter/.last_build_id -.dart_tool/ -.flutter-plugins-dependencies -.pub-cache/ -.pub/ -/build/ - -# Symbolication related -app.*.symbols - -# Obfuscation related -app.*.map.json - -# Android Studio will place build artifacts here -/android/app/debug -/android/app/profile -/android/app/release diff --git a/packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/preview_manifest.json b/packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/preview_manifest.json deleted file mode 100644 index e7f5dba51100d..0000000000000 --- a/packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/preview_manifest.json +++ /dev/null @@ -1 +0,0 @@ -{"version":"0.0.1","sdk-version":"3.8.0 (build 3.8.0-227.0.dev)","pubspec-hash":"49d61ee8be4dbdce668cba26b60bb4b2"} \ No newline at end of file diff --git a/packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/test/template_change_detection_smoke_test.dart b/packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/test/template_change_detection_smoke_test.dart deleted file mode 100644 index e9e3beb5a7443..0000000000000 --- a/packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/test/template_change_detection_smoke_test.dart +++ /dev/null @@ -1,31 +0,0 @@ -// Copyright 2014 The Flutter Authors. All rights reserved. -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file. - -import 'dart:io'; - -import 'package:flutter_test/flutter_test.dart'; - -import 'widget_preview_scaffold_change_detector.dart'; - -void main() { - test('Widget Preview Scaffold template change detection', () { - if (WidgetPreviewScaffoldChangeDetector.checkForTemplateUpdates( - widgetPreviewScaffoldProject: Directory( - Platform.script.resolve('.').path, - ), - widgetPreviewScaffoldTemplateDir: Directory( - '../../../templates/widget_preview_scaffold', - ), - )) { - stdout.writeln( - 'The widget_preview_scaffold contents do not match the widget_preview_scaffold ' - 'templates. Run "dart test/widget_preview_scaffold.shard/update_widget_preview_scaffold.dart" ' - 'to update widget_preview_scaffold with the latest template contents.', - ); - fail( - 'widget_preview_scaffold.shard/widget_preview_scaffold is not up to date.', - ); - } - }); -} diff --git a/packages/flutter_tools/tool/run_widget_preview_from_source.dart b/packages/flutter_tools/tool/run_widget_preview_from_source.dart index ccc996a805440..79f5f61f8b09d 100644 --- a/packages/flutter_tools/tool/run_widget_preview_from_source.dart +++ b/packages/flutter_tools/tool/run_widget_preview_from_source.dart @@ -12,7 +12,7 @@ import 'package:path/path.dart' as path; /// This script operates on the CWD and: /// - Deletes .dart_tool/widget_preview_scaffold/ /// - Deletes the contents of $FLUTTER_ROOT/packages/flutter_tools/templates/widget_preview_scaffold/lib/ -/// - Copies the contents of $FLUTTER_ROOT/packages/flutter_tools/test/widget_preview_scaffold.shard/widget_preview_scaffold/lib/ +/// - Copies the contents of $FLUTTER_ROOT/dev/integration_tests/widget_preview_scaffold/lib/ /// to $FLUTTER_ROOT/packages/flutter_tools/templates/widget_preview_scaffold/lib/ with the /// correct template extension /// - Runs `flutter widget-preview start` with all arguments passed to this script. @@ -29,7 +29,7 @@ Future main(List args) async { final widgetPreviewScaffoldLibDir = Directory( Platform.script - .resolve('../test/widget_preview_scaffold.shard/widget_preview_scaffold/lib') + .resolve('../../../dev/integration_tests/widget_preview_scaffold/lib') .toFilePath(), ); diff --git a/packages/shorebird_tests/.gitignore b/packages/shorebird_tests/.gitignore new file mode 100644 index 0000000000000..3a85790408401 --- /dev/null +++ b/packages/shorebird_tests/.gitignore @@ -0,0 +1,3 @@ +# https://dart.dev/guides/libraries/private-files +# Created by `dart pub` +.dart_tool/ diff --git a/packages/shorebird_tests/README.md b/packages/shorebird_tests/README.md new file mode 100644 index 0000000000000..b87fb24a480da --- /dev/null +++ b/packages/shorebird_tests/README.md @@ -0,0 +1,2 @@ +A dart project that includes tests that perform asserts in the modifications +made on the Flutter framework by the Shorebird team. diff --git a/packages/shorebird_tests/analysis_options.yaml b/packages/shorebird_tests/analysis_options.yaml new file mode 100644 index 0000000000000..a767d79d7f4b1 --- /dev/null +++ b/packages/shorebird_tests/analysis_options.yaml @@ -0,0 +1,2 @@ +# This file configures the static analysis results for your project (errors, +include: package:lints/recommended.yaml diff --git a/packages/shorebird_tests/pubspec.yaml b/packages/shorebird_tests/pubspec.yaml new file mode 100644 index 0000000000000..d0fba0c1fa95d --- /dev/null +++ b/packages/shorebird_tests/pubspec.yaml @@ -0,0 +1,16 @@ +name: shorebird_tests +description: Shorebird's Flutter customizations tests +version: 1.0.0 + +environment: + sdk: ^3.3.4 + +dependencies: + archive: ^3.5.1 + path: ^1.9.0 + yaml: ^3.1.2 + +dev_dependencies: + lints: ^3.0.0 + meta: ^1.15.0 + test: ^1.24.0 diff --git a/packages/shorebird_tests/test/android_test.dart b/packages/shorebird_tests/test/android_test.dart new file mode 100644 index 0000000000000..1e0a6da4f3824 --- /dev/null +++ b/packages/shorebird_tests/test/android_test.dart @@ -0,0 +1,94 @@ +import 'package:test/test.dart'; + +import 'shorebird_tests.dart'; + +void main() { + setUpAll(warmUpTemplateProject); + + group('shorebird android projects', () { + testWithShorebirdProject('can build an apk', (projectDirectory) async { + await projectDirectory.runFlutterBuildApk(); + + expect(projectDirectory.apkFile().existsSync(), isTrue); + expect(projectDirectory.shorebirdFile.existsSync(), isTrue); + expect(projectDirectory.getGeneratedAndroidShorebirdYaml(), completes); + }); + + group('when passing the public key through the environment variable', () { + testWithShorebirdProject( + 'adds the public key on top of the original file', + (projectDirectory) async { + final originalYaml = projectDirectory.shorebirdYaml; + + const base64PublicKey = 'public_123'; + await projectDirectory.runFlutterBuildApk( + environment: { + 'SHOREBIRD_PUBLIC_KEY': base64PublicKey, + }, + ); + + final generatedYaml = + await projectDirectory.getGeneratedAndroidShorebirdYaml(); + + expect( + generatedYaml.keys, + containsAll(originalYaml.keys), + ); + + expect( + generatedYaml['patch_public_key'], + equals(base64PublicKey), + ); + }, + ); + }); + + group('when building with a flavor', () { + testWithShorebirdProject( + 'correctly changes the app id', + (projectDirectory) async { + await projectDirectory.addProjectFlavors(); + projectDirectory.addShorebirdFlavors(); + + await projectDirectory.runFlutterBuildApk(flavor: 'internal'); + + final generatedYaml = + await projectDirectory.getGeneratedAndroidShorebirdYaml( + flavor: 'internal', + ); + + expect(generatedYaml['app_id'], equals('internal_123')); + }, + ); + + group('when public key passed through environment variable', () { + testWithShorebirdProject( + 'correctly changes the app id and adds the public key', + (projectDirectory) async { + const base64PublicKey = 'public_123'; + await projectDirectory.addProjectFlavors(); + projectDirectory.addShorebirdFlavors(); + + await projectDirectory.runFlutterBuildApk( + flavor: 'internal', + environment: { + 'SHOREBIRD_PUBLIC_KEY': base64PublicKey, + }, + ); + + final generatedYaml = + await projectDirectory.getGeneratedAndroidShorebirdYaml( + flavor: 'internal', + ); + + expect(generatedYaml['app_id'], equals('internal_123')); + expect( + generatedYaml['patch_public_key'], + equals(base64PublicKey), + ); + }, + ); + }); + }); + }); +} diff --git a/packages/shorebird_tests/test/base_test.dart b/packages/shorebird_tests/test/base_test.dart new file mode 100644 index 0000000000000..ce9b0564b1396 --- /dev/null +++ b/packages/shorebird_tests/test/base_test.dart @@ -0,0 +1,17 @@ +import 'package:test/test.dart'; + +import 'shorebird_tests.dart'; + +void main() { + setUpAll(warmUpTemplateProject); + + group('shorebird helpers', () { + testWithShorebirdProject('can build a base project', + (projectDirectory) async { + expect(projectDirectory.existsSync(), isTrue); + + expect(projectDirectory.pubspecFile.existsSync(), isTrue); + expect(projectDirectory.shorebirdFile.existsSync(), isTrue); + }); + }); +} diff --git a/packages/shorebird_tests/test/ios_test.dart b/packages/shorebird_tests/test/ios_test.dart new file mode 100644 index 0000000000000..bb694405ae946 --- /dev/null +++ b/packages/shorebird_tests/test/ios_test.dart @@ -0,0 +1,93 @@ +import 'package:test/test.dart'; + +import 'shorebird_tests.dart'; + +void main() { + setUpAll(warmUpTemplateProject); + + group( + 'shorebird ios projects', + () { + testWithShorebirdProject('can build', (projectDirectory) async { + await projectDirectory.runFlutterBuildIos(); + + expect(projectDirectory.iosArchiveFile().existsSync(), isTrue); + expect(projectDirectory.getGeneratedIosShorebirdYaml(), completes); + }); + + group('when passing the public key through the environment variable', () { + testWithShorebirdProject( + 'adds the public key on top of the original file', + (projectDirectory) async { + final originalYaml = projectDirectory.shorebirdYaml; + + const base64PublicKey = 'public_123'; + await projectDirectory.runFlutterBuildIos( + environment: { + 'SHOREBIRD_PUBLIC_KEY': base64PublicKey, + }, + ); + + final generatedYaml = + await projectDirectory.getGeneratedIosShorebirdYaml(); + + expect( + generatedYaml.keys, + containsAll(originalYaml.keys), + ); + + expect( + generatedYaml['patch_public_key'], + equals(base64PublicKey), + ); + }, + ); + }); + + group('when building with a flavor', () { + testWithShorebirdProject( + 'correctly changes the app id', + (projectDirectory) async { + await projectDirectory.addProjectFlavors(); + projectDirectory.addShorebirdFlavors(); + + await projectDirectory.runFlutterBuildIos(flavor: 'internal'); + + final generatedYaml = + await projectDirectory.getGeneratedIosShorebirdYaml(); + + expect(generatedYaml['app_id'], equals('internal_123')); + }, + ); + + group('when public key passed through environment variable', () { + testWithShorebirdProject( + 'correctly changes the app id and adds the public key', + (projectDirectory) async { + const base64PublicKey = 'public_123'; + await projectDirectory.addProjectFlavors(); + projectDirectory.addShorebirdFlavors(); + + await projectDirectory.runFlutterBuildIos( + flavor: 'internal', + environment: { + 'SHOREBIRD_PUBLIC_KEY': base64PublicKey, + }, + ); + + final generatedYaml = + await projectDirectory.getGeneratedIosShorebirdYaml(); + + expect(generatedYaml['app_id'], equals('internal_123')); + expect( + generatedYaml['patch_public_key'], + equals(base64PublicKey), + ); + }, + ); + }); + }); + }, + testOn: 'mac-os', + ); +} diff --git a/packages/shorebird_tests/test/shorebird_tests.dart b/packages/shorebird_tests/test/shorebird_tests.dart new file mode 100644 index 0000000000000..e63a8d99d89b6 --- /dev/null +++ b/packages/shorebird_tests/test/shorebird_tests.dart @@ -0,0 +1,398 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:archive/archive_io.dart'; +import 'package:path/path.dart' as path; + +import 'package:meta/meta.dart'; +import 'package:test/test.dart'; +import 'package:yaml/yaml.dart'; + +/// This will be the path to the flutter binary housed in this flutter repository. +/// +/// Which since we are running the tests from this inner package , we need to go up two directories +/// in order to find the flutter binary in the bin folder. +File get _flutterBinaryFile => File( + path.join( + Directory.current.path, + '..', + '..', + 'bin', + 'flutter${Platform.isWindows ? '.bat' : ''}', + ), + ); + +/// Whether to print line-by-line subprocess output. +/// +/// Set the `VERBOSE` environment variable to enable streaming output, +/// which is useful for debugging timeouts in CI. +final bool _verbose = Platform.environment.containsKey('VERBOSE'); + +/// Runs a flutter command using the correct binary ([_flutterBinaryFile]) with the given arguments. +/// +/// Streams stdout and stderr to the test output in real time when [_verbose] +/// is true, so CI logs show progress even if the process hangs or times out. +Future _runFlutterCommand( + List arguments, { + required Directory workingDirectory, + Map? environment, +}) async { + final String command = 'flutter ${arguments.join(' ')}'; + print('[$command] starting...'); + final stopwatch = Stopwatch()..start(); + + final Process process = await Process.start( + _flutterBinaryFile.absolute.path, + arguments, + workingDirectory: workingDirectory.path, + environment: { + 'FLUTTER_STORAGE_BASE_URL': 'https://download.shorebird.dev', + if (environment != null) ...environment, + }, + ); + + final StringBuffer stdoutBuffer = StringBuffer(); + final StringBuffer stderrBuffer = StringBuffer(); + + process.stdout.transform(utf8.decoder).listen((String data) { + stdoutBuffer.write(data); + if (_verbose) { + for (final String line in data.split('\n')) { + if (line.isNotEmpty) { + print(' [$command] $line'); + } + } + } + }); + + process.stderr.transform(utf8.decoder).listen((String data) { + stderrBuffer.write(data); + if (_verbose) { + for (final String line in data.split('\n')) { + if (line.isNotEmpty) { + print(' [$command] (stderr) $line'); + } + } + } + }); + + final int exitCode = await process.exitCode; + stopwatch.stop(); + print('[$command] completed in ${stopwatch.elapsed} ' + '(exit code $exitCode)'); + if (exitCode != 0 && !_verbose) { + print('[$command] stdout:\n$stdoutBuffer'); + print('[$command] stderr:\n$stderrBuffer'); + } + + return ProcessResult( + process.pid, + exitCode, + stdoutBuffer.toString(), + stderrBuffer.toString(), + ); +} + +Future _createFlutterProject(Directory projectDirectory) async { + final result = await _runFlutterCommand( + ['create', '--empty', '.'], + workingDirectory: projectDirectory, + ); + if (result.exitCode != 0) { + throw Exception('Failed to create Flutter project: ${result.stderr}'); + } +} + +/// Cached template project directory, created once and reused across tests. +/// +/// This avoids running `flutter create` for every test, which saves +/// significant time (especially the first Gradle/SDK download). +Directory? _templateProject; + +/// Creates (or returns the cached) template Flutter project with +/// shorebird.yaml configured. The first call runs `flutter create` and +/// `flutter build apk` to warm up Gradle caches. +/// +/// Call this from `setUpAll` so the expensive setup runs outside per-test +/// timeouts. +Future warmUpTemplateProject() => _getTemplateProject(); + +Future _getTemplateProject() async { + if (_templateProject != null) { + return _templateProject!; + } + + final Directory templateDir = Directory( + path.join(Directory.systemTemp.createTempSync().path, 'shorebird_template'), + )..createSync(); + + await _createFlutterProject(templateDir); + + templateDir.pubspecFile.writeAsStringSync(''' +${templateDir.pubspecFile.readAsStringSync()} + assets: + - shorebird.yaml +'''); + + File( + path.join(templateDir.path, 'shorebird.yaml'), + ).writeAsStringSync(''' +app_id: "123" +'''); + + // Warm up the Gradle cache with a throwaway build so subsequent + // per-test builds are fast and don't hit the per-test timeout. + // Skip if Gradle cache is already populated (e.g., from GHA cache restore). + final Directory gradleCache = Directory( + path.join(Platform.environment['HOME'] ?? '', '.gradle', 'caches'), + ); + final bool hasGradleCache = + gradleCache.existsSync() && gradleCache.listSync().isNotEmpty; + if (hasGradleCache) { + print('[warmup] Gradle cache exists, skipping warm-up build'); + } else { + await _runFlutterCommand( + ['build', 'apk'], + workingDirectory: templateDir, + ); + } + + _templateProject = templateDir; + return templateDir; +} + +/// Copies the template project to a fresh directory for test isolation. +Future _copyTemplateProject() async { + final Directory template = await _getTemplateProject(); + final Directory testDir = Directory( + path.join(Directory.systemTemp.createTempSync().path, 'shorebird_test'), + ); + + // Use platform copy to preserve the full directory tree efficiently. + final ProcessResult result; + if (Platform.isWindows) { + result = await Process.run('xcopy', [ + template.path, + testDir.path, + '/E', + '/I', + '/Q', + ]); + } else { + result = await Process.run('cp', ['-R', template.path, testDir.path]); + } + if (result.exitCode != 0) { + throw Exception('Failed to copy template project: ${result.stderr}'); + } + + return testDir; +} + +@isTest +Future testWithShorebirdProject(String name, + FutureOr Function(Directory projectDirectory) testFn) async { + test( + name, + () async { + final Directory projectDirectory = await _copyTemplateProject(); + + try { + await testFn(projectDirectory); + } finally { + projectDirectory.deleteSync(recursive: true); + } + }, + timeout: Timeout( + // Per-test timeout can be shorter now since the template project + // creation and Gradle warm-up happen outside the test timeout. + Duration(minutes: 6), + ), + ); +} + +extension ShorebirdProjectDirectoryOnDirectory on Directory { + File get pubspecFile => File( + path.join(this.path, 'pubspec.yaml'), + ); + + File get shorebirdFile => File( + path.join(this.path, 'shorebird.yaml'), + ); + + YamlMap get shorebirdYaml => + loadYaml(shorebirdFile.readAsStringSync()) as YamlMap; + + File get appGradleFile => File( + path.join(this.path, 'android', 'app', 'build.gradle'), + ); + + Future addPubDependency(String name, {bool dev = false}) async { + final result = await _runFlutterCommand( + ['pub', 'add', if (dev) '--dev', name], + workingDirectory: this, + ); + if (result.exitCode != 0) { + throw Exception( + 'Failed to run `flutter pub add $name`: ${result.stderr}'); + } + } + + Future addProjectFlavors() async { + await addPubDependency( + // TODO(felangel): revert to using published version once 3.29.0 support is released. + // https://github.com/AngeloAvv/flutter_flavorizr/pull/291 + 'dev:flutter_flavorizr:{"git":{"url":"https://github.com/wjlee611/flutter_flavorizr.git","ref":"chore/temp-migrate-3-29","path":"."}}', + ); + + await File( + path.join( + this.path, + 'flavorizr.yaml', + ), + ).writeAsString(''' +flavors: + playStore: + app: + name: "App" + + android: + applicationId: "com.example.shorebird_test" + ios: + bundleId: "com.example.shorebird_test" + internal: + app: + name: "App (Internal)" + + android: + applicationId: "com.example.shorebird_test.internal" + ios: + bundleId: "com.example.shorebird_test.internal" + global: + app: + name: "App (Global)" + + android: + applicationId: "com.example.shorebird_test.global" + ios: + bundleId: "com.example.shorebird_test.global" +'''); + + final result = await _runFlutterCommand( + ['pub', 'run', 'flutter_flavorizr'], + workingDirectory: this, + ); + if (result.exitCode != 0) { + throw Exception( + 'Failed to run `flutter pub run flutter_flavorizr`: ${result.stderr}'); + } + } + + void addShorebirdFlavors() { + const flavors = ''' +flavors: + global: global_123 + internal: internal_123 + playStore: playStore_123 +'''; + + final currentShorebirdContent = shorebirdFile.readAsStringSync(); + shorebirdFile.writeAsStringSync( + ''' +$currentShorebirdContent +$flavors +''', + ); + } + + Future runFlutterBuildApk({ + String? flavor, + Map? environment, + }) async { + final result = await _runFlutterCommand( + [ + 'build', + 'apk', + if (flavor != null) '--flavor=$flavor', + ], + workingDirectory: this, + environment: environment, + ); + if (result.exitCode != 0) { + throw Exception('Failed to run `flutter build apk`: ${result.stderr}'); + } + } + + Future runFlutterBuildIos({ + Map? environment, + String? flavor, + }) async { + final result = await _runFlutterCommand( + // The projects used to test are generated on spot, to make it simpler we don't + // configure any apple accounts on it, so we skip code signing here. + ['build', 'ipa', '--no-codesign', if (flavor != null) '--flavor=$flavor'], + workingDirectory: this, + environment: environment, + ); + + if (result.exitCode != 0) { + throw Exception('Failed to run `flutter build ios`: ${result.stderr}'); + } + } + + File apkFile({String? flavor}) => File( + path.join( + this.path, + 'build', + 'app', + 'outputs', + 'flutter-apk', + 'app-${flavor != null ? '$flavor-' : ''}release.apk', + ), + ); + + Directory iosArchiveFile() => Directory( + path.join( + this.path, + 'build', + 'ios', + 'archive', + 'Runner.xcarchive', + ), + ); + + Future getGeneratedAndroidShorebirdYaml({String? flavor}) async { + final decodedBytes = + ZipDecoder().decodeBytes(apkFile(flavor: flavor).readAsBytesSync()); + + await extractArchiveToDisk( + decodedBytes, path.join(this.path, 'apk-extracted')); + + final yamlString = File( + path.join( + this.path, + 'apk-extracted', + 'assets', + 'flutter_assets', + 'shorebird.yaml', + ), + ).readAsStringSync(); + return loadYaml(yamlString) as YamlMap; + } + + Future getGeneratedIosShorebirdYaml() async { + final yamlString = File( + path.join( + iosArchiveFile().path, + 'Products', + 'Applications', + 'Runner.app', + 'Frameworks', + 'App.framework', + 'flutter_assets', + 'shorebird.yaml', + ), + ).readAsStringSync(); + return loadYaml(yamlString) as YamlMap; + } +} diff --git a/pubspec.lock b/pubspec.lock index 2e3e5e1f799ed..d0417ad5e0674 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -219,13 +219,13 @@ packages: source: hosted version: "2.1.4" ffigen: - dependency: "direct main" + dependency: "direct dev" description: name: ffigen - sha256: "72d732c33557fc0ca9b46379d3deff2dadbdc539696dc0b270189e2989be20ef" + sha256: b7803707faeec4ce3c1b0c2274906504b796e3b70ad573577e72333bd1c9b3ba url: "https://pub.dev" source: hosted - version: "18.1.0" + version: "20.1.1" file: dependency: "direct main" description: @@ -470,10 +470,10 @@ packages: dependency: "direct main" description: name: matcher - sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6" + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 url: "https://pub.dev" source: hosted - version: "0.12.18" + version: "0.12.19" material_color_utilities: dependency: "direct main" description: @@ -827,26 +827,26 @@ packages: dependency: "direct main" description: name: test - sha256: "77cc98ea27006c84e71a7356cf3daf9ddbde2d91d84f77dbfe64cf0e4d9611ae" + sha256: "280d6d890011ca966ad08df7e8a4ddfab0fb3aa49f96ed6de56e3521347a9ae7" url: "https://pub.dev" source: hosted - version: "1.28.0" + version: "1.30.0" test_api: dependency: "direct main" description: name: test_api - sha256: "19a78f63e83d3a61f00826d09bc2f60e191bf3504183c001262be6ac75589fb8" + sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" url: "https://pub.dev" source: hosted - version: "0.7.8" + version: "0.7.10" test_core: dependency: "direct main" description: name: test_core - sha256: f1072617a6657e5fc09662e721307f7fb009b4ed89b19f47175d11d5254a62d4 + sha256: "0381bd1585d1a924763c308100f2138205252fb90c9d4eeaf28489ee65ccde51" url: "https://pub.dev" source: hosted - version: "0.6.14" + version: "0.6.16" typed_data: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index d3ae0e5ca71d6..d734b7e42bcc2 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -128,7 +128,7 @@ dependencies: leak_tracker_flutter_testing: 3.0.10 leak_tracker_testing: 3.0.2 logging: 1.3.0 - matcher: 0.12.18 + matcher: 0.12.19 material_color_utilities: 0.13.0 meta: 1.17.0 metrics_center: 1.0.14 @@ -170,9 +170,9 @@ dependencies: string_scanner: 1.4.1 sync_http: 0.3.1 term_glyph: 1.2.2 - test: 1.28.0 - test_api: 0.7.8 - test_core: 0.6.14 + test: 1.30.0 + test_api: 0.7.10 + test_core: 0.6.16 typed_data: 1.4.0 url_launcher: 6.3.2 url_launcher_android: 6.3.28 @@ -205,11 +205,14 @@ dependencies: yaml: 3.1.3 cli_util: 0.4.2 dart_style: 3.1.3 - ffigen: 18.1.0 file_testing: 3.0.2 flutter_lints: 6.0.0 lints: 6.0.0 pedantic: 1.11.1 quiver: 3.2.2 yaml_edit: 2.2.3 -# PUBSPEC CHECKSUM: unhors + +dev_dependencies: + ffigen: 20.1.1 + +# PUBSPEC CHECKSUM: rp9ao0 diff --git a/shorebird/ci/artifacts_manifest.template.yaml b/shorebird/ci/artifacts_manifest.template.yaml new file mode 100644 index 0000000000000..33223fced2de8 --- /dev/null +++ b/shorebird/ci/artifacts_manifest.template.yaml @@ -0,0 +1,65 @@ +# Template for artifacts_manifest.yaml +# This file is processed by shard_runner:finalize +# Variable substitution: {{flutter_engine_revision}} is replaced at generation time +# The $engine placeholder is kept as-is for Shorebird's artifact proxy + +flutter_engine_revision: {{flutter_engine_revision}} +storage_bucket: download.shorebird.dev +artifact_overrides: + # Android release artifacts + - flutter_infra_release/flutter/$engine/android-arm64-release/artifacts.zip + - flutter_infra_release/flutter/$engine/android-arm64-release/darwin-x64.zip + - flutter_infra_release/flutter/$engine/android-arm64-release/linux-x64.zip + - flutter_infra_release/flutter/$engine/android-arm64-release/symbols.zip + - flutter_infra_release/flutter/$engine/android-arm64-release/windows-x64.zip + + - flutter_infra_release/flutter/$engine/android-arm-release/artifacts.zip + - flutter_infra_release/flutter/$engine/android-arm-release/darwin-x64.zip + - flutter_infra_release/flutter/$engine/android-arm-release/linux-x64.zip + - flutter_infra_release/flutter/$engine/android-arm-release/symbols.zip + - flutter_infra_release/flutter/$engine/android-arm-release/windows-x64.zip + + - flutter_infra_release/flutter/$engine/android-x64-release/artifacts.zip + - flutter_infra_release/flutter/$engine/android-x64-release/darwin-x64.zip + - flutter_infra_release/flutter/$engine/android-x64-release/linux-x64.zip + - flutter_infra_release/flutter/$engine/android-x64-release/symbols.zip + - flutter_infra_release/flutter/$engine/android-x64-release/windows-x64.zip + + # engine_stamp.json + - flutter_infra_release/flutter/$engine/engine_stamp.json + + # Dart SDK + - flutter_infra_release/flutter/$engine/dart-sdk-darwin-arm64.zip + - flutter_infra_release/flutter/$engine/dart-sdk-darwin-x64.zip + - flutter_infra_release/flutter/$engine/dart-sdk-linux-x64.zip + - flutter_infra_release/flutter/$engine/dart-sdk-windows-x64.zip + + # Maven artifacts + - download.flutter.io/io/flutter/flutter_embedding_release/1.0.0-$engine/flutter_embedding_release-1.0.0-$engine.pom + - download.flutter.io/io/flutter/flutter_embedding_release/1.0.0-$engine/flutter_embedding_release-1.0.0-$engine.jar + - download.flutter.io/io/flutter/arm64_v8a_release/1.0.0-$engine/arm64_v8a_release-1.0.0-$engine.pom + - download.flutter.io/io/flutter/arm64_v8a_release/1.0.0-$engine/arm64_v8a_release-1.0.0-$engine.jar + - download.flutter.io/io/flutter/armeabi_v7a_release/1.0.0-$engine/armeabi_v7a_release-1.0.0-$engine.pom + - download.flutter.io/io/flutter/armeabi_v7a_release/1.0.0-$engine/armeabi_v7a_release-1.0.0-$engine.jar + - download.flutter.io/io/flutter/x86_64_release/1.0.0-$engine/x86_64_release-1.0.0-$engine.pom + - download.flutter.io/io/flutter/x86_64_release/1.0.0-$engine/x86_64_release-1.0.0-$engine.jar + + # Common release artifacts + - flutter_infra_release/flutter/$engine/flutter_patched_sdk_product.zip + + # iOS release artifacts + - flutter_infra_release/flutter/$engine/ios-release/artifacts.zip + - flutter_infra_release/flutter/$engine/ios-release/Flutter.framework.dSYM.zip + + # Linux release artifacts + - flutter_infra_release/flutter/$engine/linux-x64/artifacts.zip + - flutter_infra_release/flutter/$engine/linux-x64-release/linux-x64-flutter-gtk.zip + + # macOS release artifacts + - flutter_infra_release/flutter/$engine/darwin-x64-release/artifacts.zip + - flutter_infra_release/flutter/$engine/darwin-x64-release/framework.zip + - flutter_infra_release/flutter/$engine/darwin-x64-release/gen_snapshot.zip + + # Windows release artifacts + - flutter_infra_release/flutter/$engine/windows-x64/artifacts.zip + - flutter_infra_release/flutter/$engine/windows-x64-release/windows-x64-flutter.zip diff --git a/shorebird/ci/compose.json b/shorebird/ci/compose.json new file mode 100644 index 0000000000000..1f6ca744d1e5d --- /dev/null +++ b/shorebird/ci/compose.json @@ -0,0 +1,30 @@ +{ + "ios-framework": { + "requires": ["ios-release", "ios-release-ext", "ios-sim-x64", "ios-sim-x64-ext", "ios-sim-arm64", "ios-sim-arm64-ext"], + "script": "flutter/sky/tools/create_ios_framework.py", + "flags": ["--dsym", "--strip"], + "path_args": { + "--arm64-out-dir": "ios_release", + "--simulator-x64-out-dir": "ios_debug_sim", + "--simulator-arm64-out-dir": "ios_debug_sim_arm64" + } + }, + "macos-framework": { + "requires": ["mac-arm64", "mac-x64"], + "script": "flutter/sky/tools/create_macos_framework.py", + "flags": ["--dsym", "--strip", "--zip"], + "path_args": { + "--arm64-out-dir": "mac_release_arm64", + "--x64-out-dir": "mac_release" + } + }, + "macos-gen-snapshot": { + "requires": ["mac-arm64", "mac-x64"], + "script": "flutter/sky/tools/create_macos_gen_snapshots.py", + "flags": ["--zip"], + "path_args": { + "--arm64-path": "mac_release_arm64/universal/gen_snapshot_arm64", + "--x64-path": "mac_release/universal/gen_snapshot_x64" + } + } +} diff --git a/shorebird/ci/internal/generate_manifest.sh b/shorebird/ci/internal/generate_manifest.sh new file mode 100755 index 0000000000000..ee9ef7ddfdf67 --- /dev/null +++ b/shorebird/ci/internal/generate_manifest.sh @@ -0,0 +1,93 @@ +#!/bin/bash + +# This script outputs an artifact_manifest.yaml mapping +# a shorebird engine revision to a flutter engine revision. +# Usage: +# ./generate_manifest.sh > artifact_manifest.yaml + +set -e + +# NOTE: If you edit this file you also may need to edit the global list +# of all known artifacts in the artifact_proxy's config.dart + +if [ "$#" -ne 1 ]; then + echo "Usage: ./generate_manifest.sh " + exit 1 +fi + +FLUTTER_ENGINE_REVISION=$1 + +cat < $MANIFEST_FILE + +# FIXME: This should not be in shell, it's too complicated/repetitive. +# Only need the libflutter.so (and flutter.jar) artifacts +# Artifact list: https://github.com/shorebirdtech/shorebird/blob/main/packages/artifact_proxy/lib/config.dart + +HOST_ARCH='darwin-x64' +ARM64_HOST_ARCH='darwin-arm64' + +INFRA_ROOT="gs://$STORAGE_BUCKET/flutter_infra_release/flutter/$ENGINE_HASH" + +# engine_stamp.json +ENGINE_STAMP_FILE=$ENGINE_OUT/engine_stamp.json +gsutil cp $ENGINE_STAMP_FILE $INFRA_ROOT/engine_stamp.json + +# Dart SDK +# This gets uploaded to flutter_infra_release/flutter/\$engine/dart-sdk-$HOST_ARCH.zip +# We also upload to the content-aware hash path to support local development branches. +CONTENT_INFRA_ROOT="gs://$STORAGE_BUCKET/flutter_infra_release/flutter/$CONTENT_HASH" + +# x64 Dart SDK +HOST_RELEASE=$ENGINE_OUT/host_release +DART_ZIP_FILE=dart-sdk-$HOST_ARCH.zip +( + cd $HOST_RELEASE; + zip -r $DART_ZIP_FILE dart-sdk +) +ZIPS_DEST=$INFRA_ROOT/$DART_ZIP_FILE +gsutil cp $HOST_RELEASE/$DART_ZIP_FILE $ZIPS_DEST +# Also upload to content-aware hash path +gsutil cp $HOST_RELEASE/$DART_ZIP_FILE $CONTENT_INFRA_ROOT/$DART_ZIP_FILE + +# arm64 Dart SDK +HOST_RELEASE_ARM64=$ENGINE_OUT/host_release_arm64 +DART_ZIP_FILE=dart-sdk-$ARM64_HOST_ARCH.zip +( + cd $HOST_RELEASE_ARM64; + zip -r $DART_ZIP_FILE dart-sdk +) +ZIPS_DEST=$INFRA_ROOT/$DART_ZIP_FILE +gsutil cp $HOST_RELEASE_ARM64/$DART_ZIP_FILE $ZIPS_DEST +# Also upload to content-aware hash path +gsutil cp $HOST_RELEASE_ARM64/$DART_ZIP_FILE $CONTENT_INFRA_ROOT/$DART_ZIP_FILE + +# We want to build flutter/tools/font_subset, but that doesn't work with +# --no-prebuilt-dart-sdk. +# https://github.com/flutter/flutter/issues/164531 +# # mac x64 host_release font_subset (ConstFinder) +# ARCH_OUT=$ENGINE_OUT/host_release +# ZIPS_OUT=$ARCH_OUT/zip_archives/$HOST_ARCH +# ZIPS_DEST=$INFRA_ROOT/darwin-x64-release +# gsutil cp $ZIPS_OUT/font-subset.zip $ZIPS_DEST/font-subset.zip + +# # mac arm64 host_release font_subset (ConstFinder) +# ARCH_OUT=$ENGINE_OUT/host_release_arm64 +# ZIPS_OUT=$ARCH_OUT/zip_archives/$HOST_ARCH +# ZIPS_DEST=$INFRA_ROOT/darwin-arm64-release +# gsutil cp $ZIPS_OUT/font-subset.zip $ZIPS_DEST/font-subset.zip + +# Android Arm64 release gen_snapshot +ARCH_OUT=$ENGINE_OUT/android_release_arm64 +ZIPS_OUT=$ARCH_OUT/zip_archives/android-arm64-release +ZIPS_DEST=$INFRA_ROOT/android-arm64-release +gsutil cp $ZIPS_OUT/$HOST_ARCH.zip $ZIPS_DEST/$HOST_ARCH.zip + +# Android Arm32 release gen_snapshot +ARCH_OUT=$ENGINE_OUT/android_release +ZIPS_OUT=$ARCH_OUT/zip_archives/android-arm-release +ZIPS_DEST=$INFRA_ROOT/android-arm-release +gsutil cp $ZIPS_OUT/$HOST_ARCH.zip $ZIPS_DEST/$HOST_ARCH.zip + +# Android x64 release gen_snapshot +ARCH_OUT=$ENGINE_OUT/android_release_x64 +ZIPS_OUT=$ARCH_OUT/zip_archives/android-x64-release +ZIPS_DEST=$INFRA_ROOT/android-x64-release +gsutil cp $ZIPS_OUT/$HOST_ARCH.zip $ZIPS_DEST/$HOST_ARCH.zip + +# Match the upload pattern from iOS: +# https://github.com/flutter/engine/commit/1d7f0c66c316a37105601b13136f890f6595aebc + +# iOS release Flutter artifacts +ARCH_OUT=$ENGINE_OUT/release +ZIPS_OUT=$ARCH_OUT +ZIPS_DEST=$INFRA_ROOT/ios-release +gsutil cp $ZIPS_OUT/artifacts.zip $ZIPS_DEST/artifacts.zip + +# iOS dSYM +gsutil cp $ZIPS_OUT/Flutter.framework.dSYM.zip $ZIPS_DEST/Flutter.framework.dSYM.zip + +# macOS framework +ARCH_OUT=$ENGINE_OUT/release +ZIPS_OUT=$ARCH_OUT/framework +ZIPS_DEST=$INFRA_ROOT/darwin-x64-release +gsutil cp $ZIPS_OUT/framework.zip $ZIPS_DEST/framework.zip + +# macOS gen_snapshot +ARCH_OUT=$ENGINE_OUT/release +ZIPS_OUT=$ARCH_OUT/snapshot +ZIPS_DEST=$INFRA_ROOT/darwin-x64-release +gsutil cp $ZIPS_OUT/gen_snapshot.zip $ZIPS_DEST/gen_snapshot.zip + +# FIXME: these should go where we're putting the arm64 macOS artifacts +# (darwin-x64-release), however, arm macs use darwin-x64-release and we +# currently only support those. We need to find a way to support both. +# macOS x64 release artifacts +# ARCH_OUT=$ENGINE_OUT/mac_release +# ZIPS_OUT=$ARCH_OUT/zip_archives/darwin-x64-release +# ZIPS_DEST=$INFRA_ROOT/darwin-x64-release +# gsutil cp $ZIPS_OUT/artifacts.zip $ZIPS_DEST/artifacts.zip + +# macOS arm64 release artifacts +ARCH_OUT=$ENGINE_OUT/mac_release_arm64 +ZIPS_OUT=$ARCH_OUT/zip_archives/darwin-arm64-release +# This looks wrong - why are we putting arm64 artifacts in darwin-x64-release +# instead of darwin-arm64-release? This is because arm macs use darwin-x64-release +# and we need to use the artifacts we've built for arm64 macs. +ZIPS_DEST=$INFRA_ROOT/darwin-x64-release +gsutil cp $ZIPS_OUT/artifacts.zip $ZIPS_DEST/artifacts.zip + +# macOS dSYM (used for symbolication, not by Flutter) +ARCH_OUT=$ENGINE_OUT/release +ZIPS_OUT=$ARCH_OUT/framework +ZIPS_DEST=$INFRA_ROOT/darwin-x64 +gsutil cp $ZIPS_OUT/FlutterMacOS.framework.dSYM.zip $ZIPS_DEST/FlutterMacOS.framework.dSYM.zip + +TMP_DIR=$(mktemp -d) + +PATCH_VERSION=0.3.0 +GH_RELEASE=https://github.com/shorebirdtech/updater/releases/download/patch-v$PATCH_VERSION/ +cd $TMP_DIR +curl -L $GH_RELEASE/patch-x86_64-apple-darwin.zip -o patch-x86_64-apple-darwin.zip +curl -L $GH_RELEASE/patch-aarch64-apple-darwin.zip -o patch-aarch64-apple-darwin.zip +curl -L $GH_RELEASE/patch-x86_64-pc-windows-msvc.zip -o patch-x86_64-pc-windows-msvc.zip +curl -L $GH_RELEASE/patch-x86_64-unknown-linux-musl.zip -o patch-x86_64-unknown-linux-musl.zip + +gsutil cp patch-x86_64-apple-darwin.zip $SHOREBIRD_ROOT/patch-darwin-x64.zip +gsutil cp patch-aarch64-apple-darwin.zip $SHOREBIRD_ROOT/patch-darwin-arm64.zip +gsutil cp patch-x86_64-pc-windows-msvc.zip $SHOREBIRD_ROOT/patch-windows-x64.zip +gsutil cp patch-x86_64-unknown-linux-musl.zip $SHOREBIRD_ROOT/patch-linux-x64.zip + +gsutil cp $MANIFEST_FILE $SHOREBIRD_ROOT/artifacts_manifest.yaml diff --git a/shorebird/ci/internal/win_build.sh b/shorebird/ci/internal/win_build.sh new file mode 100755 index 0000000000000..9c47369707d34 --- /dev/null +++ b/shorebird/ci/internal/win_build.sh @@ -0,0 +1,63 @@ +#!/bin/bash -e + +# Usage: +# ./win_build.sh engine_path + +ENGINE_ROOT=$1 + +ENGINE_SRC=$ENGINE_ROOT/src +ENGINE_OUT=$ENGINE_SRC/out +UPDATER_SRC=$ENGINE_SRC/flutter/third_party/updater +HOST_ARCH='windows-x64' + +# The Rust updater library is now built as part of the GN/Ninja engine +# build โ€” see //flutter/shell/common/shorebird/BUILD.gn's +# build_rust_updater action. Any engine target that depends (transitively) +# on //flutter/shell/common/shorebird:updater pulls in updater.lib +# automatically. + +# Compile the engine using the steps here: +# https://github.com/flutter/flutter/wiki/Compiling-the-engine#compiling-for-android-from-macos-or-linux +cd $ENGINE_SRC + +NINJA="ninja" +GN=./flutter/tools/gn +# We could probably use our own prebuilt dart SDK, by modifying the gn files. +GN_ARGS="--no-rbe --no-enable-unittests" + +# Windows only needs gen_snapshot for each Android CPU type. +# See https://github.com/flutter/engine/blob/e590b24f3962fda3ec9144dcee3f7565b195839a/ci/builders/windows_android_aot_engine.json + +TARGETS="archive_win_gen_snapshot" + +# Build host_release +$GN $GN_ARGS --runtime-mode=release --no-prebuilt-dart-sdk +$NINJA -C out/host_release dart_sdk +# We want to build flutter/tools/font_subset, but that doesn't work with +# --no-prebuilt-dart-sdk. +# https://github.com/flutter/flutter/issues/164531 + +# Build windows desktop targets +$GN $GN_ARGS --runtime-mode=release --no-prebuilt-dart-sdk +$NINJA -C ./out/host_release flutter/build/archives:windows_flutter gen_snapshot windows flutter/build/archives:artifacts + +# Build debug Windows artifacts +# These are output to the `windows-x64` directory in host_debug, and are used +# by `flutter build windows --release`. +$GN $GN_ARGS --no-prebuilt-dart-sdk +$NINJA -C ./out/host_debug flutter/build/archives:artifacts + +# If this gives you trouble, try using VS2019 instead. I had trouble with 2022. +# Android arm64 release +$GN $GN_ARGS --android --android-cpu=arm64 --runtime-mode=release +$NINJA -C ./out/android_release_arm64 $TARGETS + +# Android arm32 release +$GN $GN_ARGS --runtime-mode=release --android +$NINJA -C out/android_release $TARGETS + +# Android x64 release +$GN $GN_ARGS --android --android-cpu=x64 --runtime-mode=release +$NINJA -C ./out/android_release_x64 $TARGETS + +# We could also build the `patch` tool for Windows here. diff --git a/shorebird/ci/internal/win_setup.sh b/shorebird/ci/internal/win_setup.sh new file mode 100755 index 0000000000000..971135644f66b --- /dev/null +++ b/shorebird/ci/internal/win_setup.sh @@ -0,0 +1,8 @@ +#!/bin/bash -e + +# Usage: +# ./windows_setup.sh + +# Add the MSVC toolchain to Rust. +rustup target add \ + x86_64-pc-windows-msvc diff --git a/shorebird/ci/internal/win_upload.sh b/shorebird/ci/internal/win_upload.sh new file mode 100755 index 0000000000000..952457109e40c --- /dev/null +++ b/shorebird/ci/internal/win_upload.sh @@ -0,0 +1,90 @@ +#!/bin/bash -e + +# Usage: +# ./win_upload.sh engine_path git_hash +ENGINE_ROOT=$1 +ENGINE_HASH=$2 + +STORAGE_BUCKET="download.shorebird.dev" +SHOREBIRD_ROOT=gs://$STORAGE_BUCKET/shorebird/$ENGINE_HASH + +ENGINE_SRC=$ENGINE_ROOT/src +ENGINE_OUT=$ENGINE_SRC/out +ENGINE_FLUTTER=$ENGINE_SRC/flutter +# FLUTTER_ROOT is the Flutter monorepo root (parent of engine/) +FLUTTER_ROOT=$(dirname $ENGINE_ROOT) + +cd $FLUTTER_ROOT + +# Compute the content-aware hash for the Dart SDK. +# This allows Flutter checkouts that haven't changed engine content to share +# the same pre-built Dart SDK, even if they have different git commit SHAs. +CONTENT_HASH=$($FLUTTER_ROOT/bin/internal/content_aware_hash.sh) + +# We do not generate a manifest file, we assume another builder did that. + +# TODO(eseidel): This should not be in shell, it's too complicated/repetitive. + +HOST_ARCH='windows-x64' + +INFRA_ROOT="gs://$STORAGE_BUCKET/flutter_infra_release/flutter/$ENGINE_HASH" + +# Dart SDK +# This gets uploaded to flutter_infra_release/flutter/\$engine/dart-sdk-$HOST_ARCH.zip +# We also upload to the content-aware hash path to support local development branches. +CONTENT_INFRA_ROOT="gs://$STORAGE_BUCKET/flutter_infra_release/flutter/$CONTENT_HASH" + +DART_SDK_DIR=$ENGINE_OUT/host_release/dart-sdk +DART_ZIP_FILE=dart-sdk-$HOST_ARCH.zip + +# Use 7zip to compress the Dart SDK, as zip isn't available on Windows and +# Powershell, which we would normally use in the form of +# `powershell Compress-Archive dart-sdk dart-sdk.zip`, doesn't play nicely +# with git bash paths (e.g. /c/Users/... instead of C:/Users/...) +/c/Program\ Files/7-Zip/7z a $DART_ZIP_FILE $DART_SDK_DIR +ZIPS_DEST=$INFRA_ROOT/$DART_ZIP_FILE +gsutil cp $DART_ZIP_FILE $ZIPS_DEST +# Also upload to content-aware hash path +gsutil cp $DART_ZIP_FILE $CONTENT_INFRA_ROOT/$DART_ZIP_FILE + +# Android Arm64 release gen_snapshot +ARCH_OUT=$ENGINE_OUT/android_release_arm64 +ZIPS_OUT=$ARCH_OUT/zip_archives/android-arm64-release +ZIPS_DEST=$INFRA_ROOT/android-arm64-release +gsutil cp $ZIPS_OUT/$HOST_ARCH.zip $ZIPS_DEST/$HOST_ARCH.zip + +# Android Arm32 release gen_snapshot +ARCH_OUT=$ENGINE_OUT/android_release +ZIPS_OUT=$ARCH_OUT/zip_archives/android-arm-release +ZIPS_DEST=$INFRA_ROOT/android-arm-release +gsutil cp $ZIPS_OUT/$HOST_ARCH.zip $ZIPS_DEST/$HOST_ARCH.zip + +# Android x64 release gen_snapshot +ARCH_OUT=$ENGINE_OUT/android_release_x64 +ZIPS_OUT=$ARCH_OUT/zip_archives/android-x64-release +ZIPS_DEST=$INFRA_ROOT/android-x64-release +gsutil cp $ZIPS_OUT/$HOST_ARCH.zip $ZIPS_DEST/$HOST_ARCH.zip + +# We could upload patch if we built it here. +# gsutil cp $ENGINE_OUT/host_release/patch.zip $SHOREBIRD_ROOT/patch-win-x64.zip + +# Engine release artifacts +ARCH_OUT=$ENGINE_OUT/host_release +ZIPS_OUT=$ARCH_OUT/zip_archives/$HOST_ARCH-release +ZIPS_DEST=$INFRA_ROOT/$HOST_ARCH-release +gsutil cp $ZIPS_OUT/$HOST_ARCH-flutter.zip $ZIPS_DEST/$HOST_ARCH-flutter.zip + +# We want to build flutter/tools/font_subset, but that doesn't work with +# --no-prebuilt-dart-sdk. +# https://github.com/flutter/flutter/issues/164531 +# # Windows x64 host_release font_subset (ConstFinder) +# ARCH_OUT=$ENGINE_OUT/host_release +# ZIPS_OUT=$ARCH_OUT/zip_archives/$HOST_ARCH +# ZIPS_DEST=$INFRA_ROOT/windows-x64-release +# gsutil cp $ZIPS_OUT/font-subset.zip $ZIPS_DEST/font-subset.zip + +# Engine debug artifacts (not sure why this is needed?) +ARCH_OUT=$ENGINE_OUT/host_debug +ZIPS_OUT=$ARCH_OUT/zip_archives/$HOST_ARCH +ZIPS_DEST=$INFRA_ROOT/$HOST_ARCH +gsutil cp $ZIPS_OUT/artifacts.zip $ZIPS_DEST/artifacts.zip diff --git a/shorebird/ci/linux_build_and_upload.sh b/shorebird/ci/linux_build_and_upload.sh new file mode 100755 index 0000000000000..9657e14178695 --- /dev/null +++ b/shorebird/ci/linux_build_and_upload.sh @@ -0,0 +1,32 @@ +#!/bin/bash -e + +# Usage: +# ./linux_build_and_upload.sh flutter_root engine_hash +# +# This is the main entrypoint for building and uploading Linux engine artifacts. +# It is called from the _build_engine repository's CI scripts. + +if [ "$#" -ne 2 ]; then + echo "Usage: $0 flutter_root engine_hash" + exit 1 +fi + +FLUTTER_ROOT=$1 +ENGINE_HASH=$2 +ENGINE_ROOT=$FLUTTER_ROOT/engine + +# Get the absolute path to the directory of this script. +SCRIPT_DIR=$(cd $(dirname $0) && pwd) + +echo "Building engine at $ENGINE_ROOT and uploading to gs://download.shorebird.dev" + +cd $SCRIPT_DIR + +# Run the setup script. +./internal/linux_setup.sh + +# Then run the build. +./internal/linux_build.sh $ENGINE_ROOT + +# Copy Shorebird engine artifacts to Google Cloud Storage. +./internal/linux_upload.sh $ENGINE_ROOT $ENGINE_HASH diff --git a/shorebird/ci/mac_build_and_upload.sh b/shorebird/ci/mac_build_and_upload.sh new file mode 100755 index 0000000000000..9ee302620d207 --- /dev/null +++ b/shorebird/ci/mac_build_and_upload.sh @@ -0,0 +1,32 @@ +#!/bin/bash -e + +# Usage: +# ./mac_build_and_upload.sh flutter_root engine_hash +# +# This is the main entrypoint for building and uploading macOS engine artifacts. +# It is called from the _build_engine repository's CI scripts. + +if [ "$#" -ne 2 ]; then + echo "Usage: $0 flutter_root engine_hash" + exit 1 +fi + +FLUTTER_ROOT=$1 +ENGINE_HASH=$2 +ENGINE_ROOT=$FLUTTER_ROOT/engine + +# Get the absolute path to the directory of this script. +SCRIPT_DIR=$(cd $(dirname $0) && pwd) + +echo "Building engine at $ENGINE_ROOT and uploading to gs://download.shorebird.dev" + +cd $SCRIPT_DIR + +# Run the setup script. +./internal/mac_setup.sh + +# Then run the build. +./internal/mac_build.sh $ENGINE_ROOT + +# Copy Shorebird engine artifacts to Google Cloud Storage. +./internal/mac_upload.sh $ENGINE_ROOT $ENGINE_HASH diff --git a/shorebird/ci/shard_runner/.gitignore b/shorebird/ci/shard_runner/.gitignore new file mode 100644 index 0000000000000..929f3d7e632c0 --- /dev/null +++ b/shorebird/ci/shard_runner/.gitignore @@ -0,0 +1,2 @@ +# Override root .gitignore to track our lockfile +!pubspec.lock diff --git a/shorebird/ci/shard_runner/analysis_options.yaml b/shorebird/ci/shard_runner/analysis_options.yaml new file mode 100644 index 0000000000000..d04adaf90938a --- /dev/null +++ b/shorebird/ci/shard_runner/analysis_options.yaml @@ -0,0 +1,7 @@ +include: package:lints/recommended.yaml + +analyzer: + language: + strict-casts: true + strict-inference: true + strict-raw-types: true diff --git a/shorebird/ci/shard_runner/bin/compare_buckets.dart b/shorebird/ci/shard_runner/bin/compare_buckets.dart new file mode 100644 index 0000000000000..958c238bb0230 --- /dev/null +++ b/shorebird/ci/shard_runner/bin/compare_buckets.dart @@ -0,0 +1,158 @@ +import 'dart:io'; + +import 'package:args/args.dart'; +import 'package:path/path.dart' as p; +import 'package:shard_runner/config.dart'; + +/// Compares artifacts between two GCS buckets for a given engine revision. +/// +/// Usage: dart run shard_runner:compare_buckets [options] +/// +/// Example: +/// dart run shard_runner:compare_buckets \ +/// --engine-revision abc123 \ +/// --test-bucket shorebird-build-test \ +/// --production-bucket download.shorebird.dev +Future main(List args) async { + final ArgParser parser = ArgParser() + ..addOption('engine-revision', + abbr: 'r', help: 'Engine revision (git hash)', mandatory: true) + ..addOption('test-bucket', + abbr: 't', help: 'Test bucket to compare', mandatory: true) + ..addOption('production-bucket', + abbr: 'p', + help: 'Production bucket (default: download.shorebird.dev)', + defaultsTo: 'download.shorebird.dev') + ..addOption('config-dir', + abbr: 'c', help: 'Config directory containing shards/*.json') + ..addFlag('verbose', abbr: 'v', help: 'Show detailed output') + ..addFlag('help', abbr: 'h', negatable: false, help: 'Show this help'); + + final ArgResults results = parser.parse(args); + + if (results['help'] as bool) { + print('Usage: dart run shard_runner:compare_buckets [options]'); + print(''); + print('Compares artifacts between test and production GCS buckets.'); + print('Uses gsutil hash to compare file checksums (MD5 + CRC32C).'); + print(''); + print(parser.usage); + exit(0); + } + + final String engineRevision = results['engine-revision'] as String; + final String testBucket = results['test-bucket'] as String; + final String productionBucket = results['production-bucket'] as String; + final String? configDirPath = results['config-dir'] as String?; + final bool verbose = results['verbose'] as bool; + + // Find config directory + final String configDir = configDirPath ?? + p.join(p.dirname(p.dirname(Platform.script.toFilePath())), 'shards'); + + print('=' * 60); + print('Compare Buckets'); + print('=' * 60); + print('Engine revision: $engineRevision'); + print('Test bucket: $testBucket'); + print('Production bucket: $productionBucket'); + print('Config dir: $configDir'); + print(''); + + // Load configs to get artifact paths + const List platforms = ['linux', 'macos', 'windows']; + final Map configs = {}; + for (final String platform in platforms) { + configs[platform] = PlatformConfig.load(platform, configDir); + } + + // Collect all artifact paths + final List artifacts = []; + for (final PlatformConfig config in configs.values) { + for (final ShardDef shard in config.shards.values) { + for (final ArtifactDef artifact in shard.artifacts) { + final String dstPath = + artifact.dst.replaceAll(r'$engine', engineRevision); + artifacts.add(dstPath); + } + } + } + + // Add manifest + artifacts.add('shorebird/$engineRevision/artifacts_manifest.yaml'); + + print('Comparing ${artifacts.length} artifacts...\n'); + + int matches = 0; + int mismatches = 0; + int missing = 0; + + for (final String artifact in artifacts) { + final String testUri = 'gs://$testBucket/$artifact'; + final String prodUri = 'gs://$productionBucket/$artifact'; + + // Get hash from test bucket + final String? testHash = await _getHash(testUri); + if (testHash == null) { + if (verbose) print('[MISSING] $artifact (not in test bucket)'); + missing++; + continue; + } + + // Get hash from production bucket + final String? prodHash = await _getHash(prodUri); + if (prodHash == null) { + if (verbose) print('[MISSING] $artifact (not in production bucket)'); + missing++; + continue; + } + + // Compare hashes + if (testHash == prodHash) { + if (verbose) print('[OK] $artifact'); + matches++; + } else { + print('[MISMATCH] $artifact'); + print(' Test: $testHash'); + print(' Prod: $prodHash'); + mismatches++; + } + } + + print(''); + print('=' * 60); + print('Results:'); + print(' Matches: $matches'); + print(' Mismatches: $mismatches'); + print(' Missing: $missing'); + print('=' * 60); + + if (mismatches > 0) { + print('\nWARNING: Found $mismatches mismatched artifacts!'); + exit(1); + } else if (missing > 0) { + print('\nWARNING: Found $missing missing artifacts.'); + exit(2); + } else { + print('\nSUCCESS: All artifacts match!'); + } +} + +/// Gets the MD5 hash of a GCS object using gsutil hash. +Future _getHash(String uri) async { + final ProcessResult result = + await Process.run('gsutil', ['hash', uri]); + if (result.exitCode != 0) { + return null; + } + + // Parse output for MD5 hash + // Example output: + // Hashes [hex] for gs://bucket/path: + // Hash (crc32c): abc123== + // Hash (md5): xyz789== + final String output = result.stdout as String; + final RegExpMatch? md5Match = + RegExp(r'Hash \(md5\):\s+(\S+)').firstMatch(output); + return md5Match?.group(1); +} diff --git a/shorebird/ci/shard_runner/bin/compose.dart b/shorebird/ci/shard_runner/bin/compose.dart new file mode 100644 index 0000000000000..73cdb44393bb7 --- /dev/null +++ b/shorebird/ci/shard_runner/bin/compose.dart @@ -0,0 +1,104 @@ +import 'dart:io'; + +import 'package:args/args.dart'; +import 'package:path/path.dart' as p; +import 'package:shard_runner/cli.dart'; +import 'package:shard_runner/compose_config.dart'; +import 'package:shard_runner/gcs.dart'; +import 'package:shard_runner/process.dart'; + +/// Composes artifacts from multiple shards into final outputs. +/// +/// Usage: dart run shard_runner:compose [options] +/// +/// Example: +/// dart run shard_runner:compose ios-framework --engine-src ~/.engine_checkout/engine/src +Future main(List args) async { + final ArgParser parser = ArgParser(); + CliConfig.addCommonOptions(parser, includeUpload: false); + parser.addFlag('download', + defaultsTo: true, help: 'Download artifacts from GCS staging'); + + final ArgResults results = parser.parse(args); + + if (results['help'] as bool || results.rest.isEmpty) { + print('Usage: dart run shard_runner:compose [options]'); + print(''); + print('Compose names: ios-framework, macos-framework, macos-gen-snapshot'); + print(''); + print(parser.usage); + exit(results['help'] as bool ? 0 : 1); + } + + final String composeName = results.rest[0]; + final CliConfig cli = + CliConfig.fromArgs(results, scriptPath: Platform.script.toFilePath()); + final bool shouldDownload = results['download'] as bool; + + cli.printHeader('Compose Runner', { + 'Compose:': composeName, + 'Download:': shouldDownload.toString(), + }); + + // Load compose config + final ComposeConfig config; + try { + config = ComposeConfig.load(cli.configDir); + } on FileSystemException catch (e) { + print('Error: ${e.message} at ${e.path}'); + exit(1); + } + + final ComposeDef composeDef; + try { + composeDef = config.getCompose(composeName); + } on ArgumentError catch (e) { + print('Error: ${e.message}'); + exit(1); + } + + print('\n[Compose] Requires shards: ${composeDef.requires.join(', ')}'); + + // Download artifacts from each required shard + if (shouldDownload) { + for (final String shard in composeDef.requires) { + print('\n[Download] Fetching $shard artifacts...'); + await downloadFromStaging( + runId: cli.runId, + platform: 'macos', // Compose only runs for macOS currently + shard: shard, + destDir: p.join(cli.engineSrc, 'out'), + ); + } + } + + // Build script arguments + final String outDir = p.join(cli.engineSrc, 'out', 'release'); + + // Ensure output directory exists + Directory(outDir).createSync(recursive: true); + + // Build script arguments: expand path_args to absolute paths, pass flags as-is. + final List expandedArgs = ['--dst', outDir]; + for (final MapEntry entry in composeDef.pathArgs.entries) { + expandedArgs + .addAll([entry.key, p.join(cli.engineSrc, 'out', entry.value)]); + } + expandedArgs.addAll(composeDef.flags); + + // Run the composition script + print('\n[Compose] Running ${composeDef.script}...'); + print('[Compose] Args: ${expandedArgs.join(' ')}'); + + await runChecked( + 'python3', + [p.join(cli.engineSrc, composeDef.script), ...expandedArgs], + workingDirectory: cli.engineSrc, + description: 'Compose ($composeName)', + ); + + print('[Compose] Complete'); + print('\n${'='.padRight(60, '=')}'); + print('Compose $composeName completed successfully'); + print('='.padRight(60, '=')); +} diff --git a/shorebird/ci/shard_runner/bin/finalize.dart b/shorebird/ci/shard_runner/bin/finalize.dart new file mode 100644 index 0000000000000..6534aedec8162 --- /dev/null +++ b/shorebird/ci/shard_runner/bin/finalize.dart @@ -0,0 +1,206 @@ +import 'dart:io'; + +import 'package:args/args.dart'; +import 'package:path/path.dart' as p; +import 'package:shard_runner/cli.dart'; +import 'package:shard_runner/config.dart'; +import 'package:shard_runner/gcs.dart'; +import 'package:shard_runner/manifest.dart'; +import 'package:shard_runner/process.dart'; + +/// Finalizes a sharded build by generating manifest and uploading artifacts. +/// +/// Usage: dart run shard_runner:finalize [options] +/// +/// Example: +/// dart run shard_runner:finalize --engine-revision abc123 +Future main(List args) async { + final ArgParser parser = ArgParser() + ..addOption('engine-revision', + abbr: 'r', help: 'Engine revision (git hash)', mandatory: true) + ..addOption('base-engine-revision', + help: 'Base Flutter engine revision for manifest') + ..addOption('content-hash', help: 'Content-aware hash for Dart SDK') + ..addOption('bucket', + abbr: 'b', + help: 'GCS bucket for uploads (default: download.shorebird.dev)', + defaultsTo: 'download.shorebird.dev') + ..addFlag('download', + defaultsTo: true, help: 'Download artifacts from GCS staging') + ..addFlag('upload', + defaultsTo: true, help: 'Upload artifacts to GCS bucket'); + + CliConfig.addCommonOptions(parser, includeUpload: false); + + final ArgResults results = parser.parse(args); + + if (results['help'] as bool) { + print('Usage: dart run shard_runner:finalize [options]'); + print(''); + print(parser.usage); + exit(0); + } + + final CliConfig cli = + CliConfig.fromArgs(results, scriptPath: Platform.script.toFilePath()); + final String engineRevision = results['engine-revision'] as String; + final String baseEngineRevision = + results['base-engine-revision'] as String? ?? engineRevision; + final String? contentHash = results['content-hash'] as String?; + final String bucket = results['bucket'] as String; + final bool shouldDownload = results['download'] as bool; + final bool shouldUpload = results['upload'] as bool; + + cli.printHeader('Finalize Build', { + 'Engine:': engineRevision, + 'Base Engine:': baseEngineRevision, + 'Bucket:': bucket, + 'Download:': shouldDownload.toString(), + 'Upload:': shouldUpload.toString(), + }); + + // Load shard configs for each platform + const List platforms = ['linux', 'macos', 'windows']; + final Map configs = {}; + for (final String platform in platforms) { + configs[platform] = PlatformConfig.load(platform, cli.configDir); + } + + // Download all artifacts from staging + if (shouldDownload) { + final String outDir = p.join(cli.engineSrc, 'out'); + Directory(outDir).createSync(recursive: true); + + for (final String platform in platforms) { + final PlatformConfig config = configs[platform]!; + for (final String shardName in config.shards.keys) { + print('\n[Download] Fetching $platform/$shardName...'); + await downloadFromStaging( + runId: cli.runId, + platform: platform, + shard: shardName, + destDir: outDir, + ); + } + } + } + + // Generate manifest + print('\n[Manifest] Generating artifacts_manifest.yaml...'); + final String manifest = + generateManifest(baseEngineRevision, configDir: cli.configDir); + final File manifestFile = + File(p.join(cli.engineSrc, 'artifacts_manifest.yaml')); + manifestFile.writeAsStringSync(manifest); + print('[Manifest] Written to ${manifestFile.path}'); + + // Upload to production + if (shouldUpload) { + print('\n[Upload] Uploading to $bucket...'); + await uploadToProduction( + engineSrc: cli.engineSrc, + engineRevision: engineRevision, + contentHash: contentHash, + configs: configs, + bucket: bucket, + ); + } + + print('\n${'=' * 60}'); + print('Finalize completed successfully'); + print('=' * 60); +} + +/// Production storage bucket name (without gs:// prefix). +const String productionBucket = 'download.shorebird.dev'; + +/// Uploads artifacts to a GCS bucket based on config definitions. +Future uploadToProduction({ + required String engineSrc, + required String engineRevision, + required String? contentHash, + required Map configs, + required String bucket, +}) async { + final String outDir = p.join(engineSrc, 'out'); + final String bucketUri = 'gs://$bucket'; + + // Helper to run gsutil cp + Future gscp(String src, String dest) async { + print('[Upload] $src -> $dest'); + await runChecked('gsutil', ['cp', src, dest], + description: 'gsutil cp $src'); + } + + // Helper to zip a directory and upload + Future zipAndUpload(String srcPath, String dest) async { + final String tempZip = '$srcPath.zip'; + print('[Zip] Creating $tempZip...'); + await runChecked( + 'zip', + ['-r', tempZip, '.'], + workingDirectory: srcPath, + description: 'zip $tempZip', + ); + await gscp(tempZip, dest); + File(tempZip).deleteSync(); + } + + // Process artifacts from all configs + for (final MapEntry entry in configs.entries) { + final String platform = entry.key; + final PlatformConfig config = entry.value; + + for (final MapEntry shardEntry in config.shards.entries) { + final String shardName = shardEntry.key; + final ShardDef shard = shardEntry.value; + + print('\n[Upload] Processing $platform/$shardName...'); + + for (final ArtifactDef artifact in shard.artifacts) { + // Resolve source path + final String srcPath = p.join(outDir, artifact.src); + + // Resolve destination path (replace $engine with actual revision) + final String dstPath = + artifact.dst.replaceAll(r'$engine', engineRevision); + final String fullDest = '$bucketUri/$dstPath'; + + // Check if source exists + final File srcFile = File(srcPath); + final Directory srcDir = Directory(srcPath); + final bool srcExists = srcFile.existsSync() || srcDir.existsSync(); + + if (!srcExists) { + print('[Skip] $srcPath (not found)'); + continue; + } + + // Handle zip flag + if (artifact.zip && srcDir.existsSync()) { + await zipAndUpload(srcPath, fullDest); + } else { + await gscp(srcPath, fullDest); + } + + // Handle content-hash uploads (for Dart SDK) + if (artifact.contentHash && contentHash != null) { + final String contentDstPath = + artifact.dst.replaceAll(r'$engine', contentHash); + final String contentFullDest = '$bucketUri/$contentDstPath'; + await gscp(srcPath, contentFullDest); + } + } + } + } + + // Upload manifest + final String manifestFile = p.join(engineSrc, 'artifacts_manifest.yaml'); + if (File(manifestFile).existsSync()) { + final String manifestDest = + '$bucketUri/shorebird/$engineRevision/artifacts_manifest.yaml'; + await gscp(manifestFile, manifestDest); + } + + print('\n[Upload] Production upload complete'); +} diff --git a/shorebird/ci/shard_runner/bin/run_shard.dart b/shorebird/ci/shard_runner/bin/run_shard.dart new file mode 100644 index 0000000000000..ed8c4cddac435 --- /dev/null +++ b/shorebird/ci/shard_runner/bin/run_shard.dart @@ -0,0 +1,84 @@ +import 'dart:io'; + +import 'package:args/args.dart'; +import 'package:shard_runner/cli.dart'; +import 'package:shard_runner/config.dart'; +import 'package:shard_runner/gcs.dart'; + +/// Runs a single build shard. +/// +/// Usage: dart run shard_runner:run_shard [options] +/// +/// Example: +/// dart run shard_runner:run_shard linux android-arm64 --engine-src ~/.engine_checkout/engine/src +Future main(List args) async { + final ArgParser parser = ArgParser(); + CliConfig.addCommonOptions(parser); + + final ArgResults results = parser.parse(args); + + if (results['help'] as bool || results.rest.length < 2) { + print( + 'Usage: dart run shard_runner:run_shard [options]'); + print(''); + print('Platforms: linux, macos, windows'); + print(''); + print(parser.usage); + exit(results['help'] as bool ? 0 : 1); + } + + final String platform = results.rest[0]; + final String shard = results.rest[1]; + final CliConfig cli = + CliConfig.fromArgs(results, scriptPath: Platform.script.toFilePath()); + + cli.printHeader('Shard Runner', { + 'Platform:': platform, + 'Shard:': shard, + 'Upload:': cli.shouldUpload.toString(), + }); + + cli.verifyEngineSrc(); + + // Load config + print('\n[Config] Loading $platform.json...'); + final PlatformConfig config = PlatformConfig.load(platform, cli.configDir); + final ShardDef shardDef = config.getShard(shard); + + print('[Config] Found ${shardDef.steps.length} step(s)'); + + // Collect output directories from GnNinja steps for upload + final List outDirs = [ + for (final BuildStep step in shardDef.steps) + if (step is GnNinjaStep) step.outDir, + ]; + + // Execute steps + final Stopwatch stopwatch = Stopwatch()..start(); + + for (int i = 0; i < shardDef.steps.length; i++) { + final BuildStep step = shardDef.steps[i]; + print( + '\n[${'Step ${i + 1}/${shardDef.steps.length}'}] ${step.runtimeType}'); + + await step.execute(cli.engineSrc); + } + + stopwatch.stop(); + print('\n[Build] Complete in ${stopwatch.elapsed}'); + + // Upload to GCS staging + if (cli.shouldUpload && outDirs.isNotEmpty) { + await uploadToStaging( + runId: cli.runId, + platform: platform, + shard: shard, + engineSrc: cli.engineSrc, + outDirs: outDirs, + ); + } + + print('\n${'='.padRight(60, '=')}'); + print('Shard $platform/$shard completed successfully'); + print('='.padRight(60, '=')); +} diff --git a/shorebird/ci/shard_runner/lib/cli.dart b/shorebird/ci/shard_runner/lib/cli.dart new file mode 100644 index 0000000000000..23a2a0b99a5a3 --- /dev/null +++ b/shorebird/ci/shard_runner/lib/cli.dart @@ -0,0 +1,83 @@ +import 'dart:io'; + +import 'package:args/args.dart'; +import 'package:meta/meta.dart'; +import 'package:path/path.dart' as p; + +/// Common CLI configuration used by shard runner scripts. +@immutable +class CliConfig { + CliConfig({ + required this.engineSrc, + required this.configDir, + required this.runId, + required this.shouldUpload, + }); + + /// Parses common options from ArgResults. + /// + /// [scriptPath] should be Platform.script.toFilePath() from the calling script. + factory CliConfig.fromArgs(ArgResults results, {required String scriptPath}) { + final String engineSrc = p.canonicalize(results['engine-src'] as String); + + // Config directory defaults to shorebird/ci (grandparent of bin/*.dart) + final String configDir = results['config-dir'] as String? ?? + p.dirname(p.dirname(p.dirname(scriptPath))); + + final String runId = results['run-id'] as String; + + final bool shouldUpload = + !results.options.contains('upload') || results['upload'] as bool; + + return CliConfig( + engineSrc: engineSrc, + configDir: configDir, + runId: runId, + shouldUpload: shouldUpload, + ); + } + final String engineSrc; + final String configDir; + final String runId; + final bool shouldUpload; + + /// Creates common argument parser options. + static void addCommonOptions(ArgParser parser, {bool includeUpload = true}) { + parser + ..addOption('engine-src', + abbr: 'e', help: 'Path to engine/src directory', mandatory: true) + ..addOption('run-id', + help: 'Build run identifier (use "local" for local development)', + mandatory: true) + ..addOption('config-dir', + abbr: 'c', help: 'Path to config directory (shorebird/ci)') + ..addFlag('help', abbr: 'h', negatable: false, help: 'Show this help'); + + if (includeUpload) { + parser.addFlag('upload', + defaultsTo: true, help: 'Upload artifacts to GCS staging'); + } + } + + /// Prints a standard header with configuration info. + void printHeader(String title, Map extra) { + print('='.padRight(60, '=')); + print(title); + print('='.padRight(60, '=')); + print('Engine: $engineSrc'); + print('Config: $configDir'); + print('Run ID: $runId'); + for (final MapEntry entry in extra.entries) { + print('${entry.key.padRight(12)}${entry.value}'); + } + print('='.padRight(60, '=')); + } + + /// Verifies that the engine source directory exists. + void verifyEngineSrc() { + if (!Directory(engineSrc).existsSync()) { + print('Error: Engine source not found at $engineSrc'); + exit(1); + } + } +} diff --git a/shorebird/ci/shard_runner/lib/compose_config.dart b/shorebird/ci/shard_runner/lib/compose_config.dart new file mode 100644 index 0000000000000..927748d326484 --- /dev/null +++ b/shorebird/ci/shard_runner/lib/compose_config.dart @@ -0,0 +1,76 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:meta/meta.dart'; +import 'package:path/path.dart' as p; + +/// Configuration for all compose operations. +@immutable +class ComposeConfig { + ComposeConfig({required this.composes}); + + factory ComposeConfig.fromJson(Map json) { + return ComposeConfig( + composes: json.map( + (String key, value) => + MapEntry(key, ComposeDef.fromJson(value as Map)), + ), + ); + } + final Map composes; + + static ComposeConfig load(String configDir) { + final File file = File(p.join(configDir, 'compose.json')); + if (!file.existsSync()) { + throw FileSystemException('compose.json not found', file.path); + } + final String content = file.readAsStringSync(); + final Map json = + jsonDecode(content) as Map; + return ComposeConfig.fromJson(json); + } + + ComposeDef getCompose(String name) { + final ComposeDef? compose = composes[name]; + if (compose == null) { + throw ArgumentError( + 'Unknown compose: $name. Available: ${composes.keys.join(', ')}'); + } + return compose; + } +} + +/// Definition of a single compose operation. +@immutable +class ComposeDef { + ComposeDef({ + required this.requires, + required this.script, + this.flags = const [], + this.pathArgs = const {}, + }); + + factory ComposeDef.fromJson(Map json) { + return ComposeDef( + requires: (json['requires'] as List).cast(), + script: json['script'] as String, + flags: (json['flags'] as List?)?.cast() ?? [], + pathArgs: (json['path_args'] as Map?) + ?.cast() ?? + {}, + ); + } + + /// Shards that must complete before this compose can run. + final List requires; + + /// Path to the Python script to execute (relative to engine/src). + final String script; + + /// Boolean flags to pass to the script (e.g., --dsym, --strip, --zip). + final List flags; + + /// Arguments whose values are paths relative to out/ (e.g., --arm64-out-dir: ios_release). + /// These are expanded to absolute paths at runtime. + final Map pathArgs; +} diff --git a/shorebird/ci/shard_runner/lib/config.dart b/shorebird/ci/shard_runner/lib/config.dart new file mode 100644 index 0000000000000..9fecdabe1cfd2 --- /dev/null +++ b/shorebird/ci/shard_runner/lib/config.dart @@ -0,0 +1,268 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:meta/meta.dart'; +import 'package:path/path.dart' as p; +import 'package:shard_runner/process.dart'; + +/// Configuration for all shards on a platform. +@immutable +class PlatformConfig { + PlatformConfig({required this.shards}); + + factory PlatformConfig.fromJson(Map json) { + return PlatformConfig( + shards: json.map( + (String key, value) => + MapEntry(key, ShardDef.fromJson(value as Map)), + ), + ); + } + final Map shards; + + ShardDef getShard(String name) { + final ShardDef? shard = shards[name]; + if (shard == null) { + throw ArgumentError( + 'Unknown shard: $name. Available: ${shards.keys.join(', ')}', + ); + } + return shard; + } + + static PlatformConfig load(String platform, String configDir) { + final File file = File(p.join(configDir, 'shards', '$platform.json')); + if (!file.existsSync()) { + throw FileSystemException('Config file not found', file.path); + } + final String content = file.readAsStringSync(); + final Map json = + jsonDecode(content) as Map; + return PlatformConfig.fromJson(json); + } +} + +/// Definition of a single build shard. +@immutable +class ShardDef { + ShardDef({ + required this.steps, + this.composeInput, + this.artifacts = const [], + }); + + factory ShardDef.fromJson(Map json) { + final List steps = (json['steps'] as List) + .map((s) => BuildStep.fromJson(s as Map)) + .toList(); + + final List artifacts = (json['artifacts'] as List?) + ?.map((a) => ArtifactDef.fromJson(a as Map)) + .toList() ?? + []; + + return ShardDef( + steps: steps, + composeInput: json['compose_input'] as String?, + artifacts: artifacts, + ); + } + + /// Build steps to execute. For simple shards, this is a single GnNinja step. + final List steps; + + /// If set, this shard contributes to a compose operation. + final String? composeInput; + + /// Artifacts produced by this shard (paths relative to out_dir). + /// Used by finalize to know what to upload. + final List artifacts; +} + +/// Definition of an artifact to upload. +@immutable +class ArtifactDef { + ArtifactDef({ + required this.src, + required this.dst, + this.zip = false, + this.contentHash = false, + }); + + factory ArtifactDef.fromJson(Map json) { + return ArtifactDef( + src: json['src'] as String, + dst: json['dst'] as String, + zip: json['zip'] as bool? ?? false, + contentHash: json['content_hash'] as bool? ?? false, + ); + } + + /// Source path relative to out/ (or out// for single-step shards) + final String src; + + /// Destination path (relative to storage bucket root). + /// Supports placeholders: $engine (engine hash) + final String dst; + + /// If true, zip the source directory before uploading. + final bool zip; + + /// If true, also upload to content-hash path (for Dart SDK). + final bool contentHash; +} + +/// Base class for build steps. +sealed class BuildStep { + factory BuildStep.fromJson(Map json) { + final String type = json['type'] as String; + return switch (type) { + 'gn_ninja' => GnNinjaStep.fromJson(json), + 'rust' => RustStep.fromJson(json), + _ => throw ArgumentError('Unknown step type: $type'), + }; + } + Future execute(String engineSrc); +} + +/// A GN + Ninja build step. +@immutable +class GnNinjaStep implements BuildStep { + GnNinjaStep({ + required this.gnArgs, + required this.ninjaTargets, + required this.outDir, + }); + + factory GnNinjaStep.fromJson(Map json) { + return GnNinjaStep( + gnArgs: (json['gn_args'] as List).cast(), + ninjaTargets: (json['ninja_targets'] as List).cast(), + outDir: json['out_dir'] as String, + ); + } + final List gnArgs; + final List ninjaTargets; + final String outDir; + + @override + Future execute(String engineSrc) async { + // Import gn.dart functions + await _runGn(engineSrc, gnArgs, outDir); + await _runNinja(engineSrc, outDir, ninjaTargets); + } +} + +/// A Rust/Cargo build step. +@immutable +class RustStep implements BuildStep { + RustStep({required this.targets}); + + factory RustStep.fromJson(Map json) { + return RustStep(targets: (json['targets'] as List).cast()); + } + final List targets; + + @override + Future execute(String engineSrc) async { + await _runRust(engineSrc, targets); + } +} + +// Internal execution functions (to be moved to separate files) +Future _runGn(String engineSrc, List args, String outDir) async { + print('[GN] Building $outDir with args: ${args.join(' ')}'); + await runChecked( + 'python3', + [ + p.join(engineSrc, 'flutter', 'tools', 'gn'), + '--no-rbe', + '--no-enable-unittests', + '--target-dir', + outDir, + ...args, + ], + workingDirectory: engineSrc, + description: 'GN ($outDir)', + ); + print('[GN] Complete'); +} + +Future _runNinja( + String engineSrc, + String outDir, + List targets, +) async { + print('[Ninja] Building ${targets.join(' ')} in out/$outDir'); + await runChecked( + 'ninja', + ['-C', p.join(engineSrc, 'out', outDir), ...targets], + workingDirectory: engineSrc, + description: 'Ninja ($outDir)', + ); + print('[Ninja] Complete'); +} + +Future _runRust(String engineSrc, List targets) async { + final String updaterPath = p.join( + engineSrc, + 'flutter', + 'third_party', + 'updater', + 'library', + ); + + // Separate Android and non-Android targets + final List androidTargets = + targets.where((String t) => t.contains('android')).toList(); + final List otherTargets = + targets.where((String t) => !t.contains('android')).toList(); + + // Build all Android targets together with cargo-ndk + if (androidTargets.isNotEmpty) { + print('[Rust] Building Android targets: ${androidTargets.join(', ')}'); + + final List args = ['ndk']; + for (final String target in androidTargets) { + args.addAll(['--target', target]); + } + args.addAll(['build', '--release']); + + // The "unmodified" CIPD package keeps the NDK at the standard Android + // SDK path: android_tools/sdk/ndk/. + final Directory ndkParent = Directory( + p.join( + engineSrc, + 'flutter', + 'third_party', + 'android_tools', + 'sdk', + 'ndk', + ), + ); + final String ndkHome = + ndkParent.listSync().whereType().first.path; + + await runChecked( + 'cargo', + args, + workingDirectory: updaterPath, + environment: {'ANDROID_NDK_HOME': ndkHome}, + description: 'Cargo ndk (${androidTargets.join(', ')})', + ); + } + + // Build non-Android targets individually + for (final String target in otherTargets) { + print('[Rust] Building for target: $target'); + + await runChecked( + 'cargo', + ['build', '--release', '--target', target], + workingDirectory: updaterPath, + description: 'Cargo ($target)', + ); + } + + print('[Rust] Complete'); +} diff --git a/shorebird/ci/shard_runner/lib/gcs.dart b/shorebird/ci/shard_runner/lib/gcs.dart new file mode 100644 index 0000000000000..c4263658bc275 --- /dev/null +++ b/shorebird/ci/shard_runner/lib/gcs.dart @@ -0,0 +1,112 @@ +import 'dart:io'; + +import 'package:path/path.dart' as p; +import 'package:shard_runner/process.dart'; + +/// Staging bucket for intermediate artifacts. +const String stagingBucket = 'gs://shorebird-build-staging'; + +/// Uploads shard artifacts to GCS staging bucket. +/// +/// Artifacts are uploaded to: +/// gs://shorebird-build-staging/builds/{runId}/{platform}/{shard}/ +Future uploadToStaging({ + required String runId, + required String platform, + required String shard, + required String engineSrc, + required List outDirs, +}) async { + final String stagingRoot = '$stagingBucket/builds/$runId/$platform/$shard'; + + print('[GCS] Uploading to $stagingRoot'); + + for (final String outDir in outDirs) { + final String outPath = p.join(engineSrc, 'out', outDir); + if (!Directory(outPath).existsSync()) { + print('[GCS] Skipping $outDir (not found)'); + continue; + } + + // Create a tarball of the out directory + final String tarFile = '$outDir.tar.gz'; + print('[GCS] Creating $tarFile...'); + + await runChecked( + 'tar', + ['-czf', tarFile, '-C', p.join(engineSrc, 'out'), outDir], + workingDirectory: engineSrc, + description: 'tar create $tarFile', + ); + + // Upload to GCS + print('[GCS] Uploading $tarFile...'); + await runChecked( + 'gsutil', + ['-m', 'cp', p.join(engineSrc, tarFile), '$stagingRoot/'], + description: 'gsutil upload $tarFile', + ); + + // Clean up local tarball + File(p.join(engineSrc, tarFile)).deleteSync(); + } + + // Upload status file + final File statusFile = File(p.join(engineSrc, 'status.json')); + statusFile.writeAsStringSync('{"status": "success", "shard": "$shard"}'); + await runChecked('gsutil', ['cp', statusFile.path, '$stagingRoot/'], + description: 'gsutil upload status.json'); + statusFile.deleteSync(); + + print('[GCS] Upload complete'); +} + +/// Downloads artifacts from GCS staging bucket. +Future downloadFromStaging({ + required String runId, + required String platform, + required String shard, + required String destDir, +}) async { + final String stagingRoot = '$stagingBucket/builds/$runId/$platform/$shard'; + + print('[GCS] Downloading from $stagingRoot'); + + // List files in the staging location + final ProcessResult lsResult = await runChecked( + 'gsutil', + ['ls', stagingRoot], + description: 'gsutil ls $stagingRoot', + ); + + final List files = (lsResult.stdout as String) + .split('\n') + .where((String f) => f.endsWith('.tar.gz')) + .toList(); + + for (final String file in files) { + final String fileName = p.basename(file); + print('[GCS] Downloading $fileName...'); + + // Download + await runChecked( + 'gsutil', + ['cp', file, p.join(destDir, fileName)], + description: 'gsutil download $fileName', + ); + + // Extract + print('[GCS] Extracting $fileName...'); + await runChecked( + 'tar', + ['-xzf', fileName], + workingDirectory: destDir, + description: 'tar extract $fileName', + ); + + // Clean up tarball + File(p.join(destDir, fileName)).deleteSync(); + } + + print('[GCS] Download complete'); +} diff --git a/shorebird/ci/shard_runner/lib/manifest.dart b/shorebird/ci/shard_runner/lib/manifest.dart new file mode 100644 index 0000000000000..db5c3a5789248 --- /dev/null +++ b/shorebird/ci/shard_runner/lib/manifest.dart @@ -0,0 +1,26 @@ +import 'dart:io'; + +import 'package:path/path.dart' as p; + +/// Generates the artifacts manifest YAML from template. +/// +/// The manifest maps a Shorebird engine revision to a Flutter engine revision +/// and lists all artifact paths that should be proxied. +/// +/// [flutterEngineRevision] is the base Flutter engine revision this build is based on. +/// [configDir] is the path to the ci/ directory containing the template. +String generateManifest( + String flutterEngineRevision, { + required String configDir, +}) { + final templatePath = p.join(configDir, 'artifacts_manifest.template.yaml'); + final templateFile = File(templatePath); + + if (!templateFile.existsSync()) { + throw ArgumentError('Manifest template not found: $templatePath'); + } + + final template = templateFile.readAsStringSync(); + return template.replaceAll( + '{{flutter_engine_revision}}', flutterEngineRevision); +} diff --git a/shorebird/ci/shard_runner/lib/process.dart b/shorebird/ci/shard_runner/lib/process.dart new file mode 100644 index 0000000000000..1fb251b609d97 --- /dev/null +++ b/shorebird/ci/shard_runner/lib/process.dart @@ -0,0 +1,66 @@ +import 'dart:io'; + +/// Resolves an executable name for Windows, appending .cmd if needed. +/// +/// On Windows, many tools like gsutil and gcloud are installed as .cmd files. +/// This function checks if a .cmd version exists and uses it. +String _resolveExecutable(String executable) { + if (!Platform.isWindows) return executable; + + // Don't modify if it already has an extension + if (executable.endsWith('.exe') || + executable.endsWith('.cmd') || + executable.endsWith('.bat')) { + return executable; + } + + // Check if .cmd version exists in PATH + final String? path = Platform.environment['PATH']; + if (path == null) return executable; + + for (final String dir in path.split(';')) { + final File cmdFile = File('$dir\\$executable.cmd'); + if (cmdFile.existsSync()) { + return '$executable.cmd'; + } + } + + return executable; +} + +/// Runs a process and throws if it exits with a non-zero code. +/// +/// Returns the [ProcessResult] for callers that need stdout/stderr. +Future runChecked( + String executable, + List arguments, { + String? workingDirectory, + Map? environment, + String? description, +}) async { + final String resolvedExecutable = _resolveExecutable(executable); + + final ProcessResult result = await Process.run( + resolvedExecutable, + arguments, + workingDirectory: workingDirectory, + environment: environment, + ); + + if (result.exitCode != 0) { + final String desc = description ?? '$executable ${arguments.join(' ')}'; + final String stderr = (result.stderr as String).trim(); + final String stdout = (result.stdout as String).trim(); + final StringBuffer message = + StringBuffer('$desc failed (exit ${result.exitCode})'); + if (stdout.isNotEmpty) { + message.write('\nSTDOUT: $stdout'); + } + if (stderr.isNotEmpty) { + message.write('\nSTDERR: $stderr'); + } + throw Exception(message.toString()); + } + + return result; +} diff --git a/shorebird/ci/shard_runner/pubspec.lock b/shorebird/ci/shard_runner/pubspec.lock new file mode 100644 index 0000000000000..14ad0cf1c27c0 --- /dev/null +++ b/shorebird/ci/shard_runner/pubspec.lock @@ -0,0 +1,389 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + sha256: "796d97d925add7ffcdf5595f33a2066a6e3cee97971e6dbef09b76b7880fd760" + url: "https://pub.dev" + source: hosted + version: "94.0.0" + analyzer: + dependency: transitive + description: + name: analyzer + sha256: "9c8ebb304d72c0a0c8764344627529d9503fc83d7d73e43ed727dc532f822e4b" + url: "https://pub.dev" + source: hosted + version: "10.0.2" + args: + dependency: "direct main" + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" + async: + dependency: transitive + description: + name: async + sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" + url: "https://pub.dev" + source: hosted + version: "2.13.0" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + cli_config: + dependency: transitive + description: + name: cli_config + sha256: ac20a183a07002b700f0c25e61b7ee46b23c309d76ab7b7640a028f18e4d99ec + url: "https://pub.dev" + source: hosted + version: "0.2.0" + collection: + dependency: "direct main" + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + convert: + dependency: transitive + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + coverage: + dependency: transitive + description: + name: coverage + sha256: "5da775aa218eaf2151c721b16c01c7676fbfdd99cebba2bf64e8b807a28ff94d" + url: "https://pub.dev" + source: hosted + version: "1.15.0" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + frontend_server_client: + dependency: transitive + description: + name: frontend_server_client + sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 + url: "https://pub.dev" + source: hosted + version: "4.0.0" + glob: + dependency: transitive + description: + name: glob + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + url: "https://pub.dev" + source: hosted + version: "2.1.3" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 + url: "https://pub.dev" + source: hosted + version: "3.2.2" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + io: + dependency: transitive + description: + name: io + sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b + url: "https://pub.dev" + source: hosted + version: "1.0.5" + lints: + dependency: "direct dev" + description: + name: lints + sha256: cbf8d4b858bb0134ef3ef87841abdf8d63bfc255c266b7bf6b39daa1085c4290 + url: "https://pub.dev" + source: hosted + version: "3.0.0" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6" + url: "https://pub.dev" + source: hosted + version: "0.12.18" + meta: + dependency: "direct main" + description: + name: meta + sha256: "9f29b9bcc8ee287b1a31e0d01be0eae99a930dbffdaecf04b3f3d82a969f296f" + url: "https://pub.dev" + source: hosted + version: "1.18.1" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + node_preamble: + dependency: transitive + description: + name: node_preamble + sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + package_config: + dependency: transitive + description: + name: package_config + sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc + url: "https://pub.dev" + source: hosted + version: "2.2.0" + path: + dependency: "direct main" + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + pool: + dependency: transitive + description: + name: pool + sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d" + url: "https://pub.dev" + source: hosted + version: "1.5.2" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + shelf: + dependency: transitive + description: + name: shelf + sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 + url: "https://pub.dev" + source: hosted + version: "1.4.2" + shelf_packages_handler: + dependency: transitive + description: + name: shelf_packages_handler + sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + shelf_static: + dependency: transitive + description: + name: shelf_static + sha256: c87c3875f91262785dade62d135760c2c69cb217ac759485334c5857ad89f6e3 + url: "https://pub.dev" + source: hosted + version: "1.1.3" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925" + url: "https://pub.dev" + source: hosted + version: "3.0.0" + source_map_stack_trace: + dependency: transitive + description: + name: source_map_stack_trace + sha256: c0713a43e323c3302c2abe2a1cc89aa057a387101ebd280371d6a6c9fa68516b + url: "https://pub.dev" + source: hosted + version: "2.1.2" + source_maps: + dependency: transitive + description: + name: source_maps + sha256: "190222579a448b03896e0ca6eca5998fa810fda630c1d65e2f78b3f638f54812" + url: "https://pub.dev" + source: hosted + version: "0.10.13" + source_span: + dependency: transitive + description: + name: source_span + sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c" + url: "https://pub.dev" + source: hosted + version: "1.10.1" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test: + dependency: "direct dev" + description: + name: test + sha256: "54c516bbb7cee2754d327ad4fca637f78abfc3cbcc5ace83b3eda117e42cd71a" + url: "https://pub.dev" + source: hosted + version: "1.29.0" + test_api: + dependency: transitive + description: + name: test_api + sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636" + url: "https://pub.dev" + source: hosted + version: "0.7.9" + test_core: + dependency: transitive + description: + name: test_core + sha256: "394f07d21f0f2255ec9e3989f21e54d3c7dc0e6e9dbce160e5a9c1a6be0e2943" + url: "https://pub.dev" + source: hosted + version: "0.6.15" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60" + url: "https://pub.dev" + source: hosted + version: "15.0.2" + watcher: + dependency: transitive + description: + name: watcher + sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 + url: "https://pub.dev" + source: hosted + version: "3.0.3" + webkit_inspection_protocol: + dependency: transitive + description: + name: webkit_inspection_protocol + sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" +sdks: + dart: ">=3.9.0 <4.0.0" diff --git a/shorebird/ci/shard_runner/pubspec.yaml b/shorebird/ci/shard_runner/pubspec.yaml new file mode 100644 index 0000000000000..8e0834004a88b --- /dev/null +++ b/shorebird/ci/shard_runner/pubspec.yaml @@ -0,0 +1,17 @@ +name: shard_runner +description: Dart-based build shard runner for Shorebird CI +version: 0.1.0 +publish_to: none + +environment: + sdk: ^3.0.0 + +dependencies: + args: ^2.4.0 + collection: ^1.18.0 + meta: ^1.11.0 + path: ^1.8.0 + +dev_dependencies: + lints: ^3.0.0 + test: ^1.24.0 diff --git a/shorebird/ci/shard_runner/test/compose_config_test.dart b/shorebird/ci/shard_runner/test/compose_config_test.dart new file mode 100644 index 0000000000000..24868a6affcc5 --- /dev/null +++ b/shorebird/ci/shard_runner/test/compose_config_test.dart @@ -0,0 +1,119 @@ +import 'package:test/test.dart'; +import 'package:shard_runner/compose_config.dart'; + +void main() { + group('ComposeConfig', () { + test('parses compose definitions', () { + final Map json = { + 'ios-framework': { + 'requires': ['ios-release', 'ios-sim-x64', 'ios-sim-arm64'], + 'script': 'flutter/sky/tools/create_ios_framework.py', + 'flags': ['--dsym', '--strip'], + 'path_args': { + '--arm64-out-dir': 'ios_release', + }, + }, + 'macos-framework': { + 'requires': ['mac-arm64', 'mac-x64'], + 'script': 'flutter/sky/tools/create_macos_framework.py', + 'flags': ['--zip'], + }, + }; + + final ComposeConfig config = ComposeConfig.fromJson(json); + + expect(config.composes.length, 2); + expect(config.composes.containsKey('ios-framework'), true); + expect(config.composes.containsKey('macos-framework'), true); + }); + + test('getCompose returns correct definition', () { + final Map json = { + 'ios-framework': { + 'requires': ['ios-release'], + 'script': 'create_ios_framework.py', + }, + }; + + final ComposeConfig config = ComposeConfig.fromJson(json); + final ComposeDef compose = config.getCompose('ios-framework'); + + expect(compose.requires, ['ios-release']); + expect(compose.script, 'create_ios_framework.py'); + }); + + test('getCompose throws for unknown name', () { + final ComposeConfig config = + ComposeConfig(composes: {}); + + expect( + () => config.getCompose('nonexistent'), + throwsA(isA()), + ); + }); + }); + + group('ComposeDef', () { + test('parses all fields', () { + final Map json = { + 'requires': ['shard-a', 'shard-b'], + 'script': 'path/to/script.py', + 'flags': ['--dsym', '--strip'], + 'path_args': { + '--arm64-out-dir': 'ios_release', + '--x64-out-dir': 'ios_debug_sim', + }, + }; + + final ComposeDef compose = ComposeDef.fromJson(json); + + expect(compose.requires, ['shard-a', 'shard-b']); + expect(compose.script, 'path/to/script.py'); + expect(compose.flags, ['--dsym', '--strip']); + expect(compose.pathArgs, { + '--arm64-out-dir': 'ios_release', + '--x64-out-dir': 'ios_debug_sim', + }); + }); + + test('defaults flags and path_args when missing', () { + final Map json = { + 'requires': ['shard-a'], + 'script': 'script.py', + }; + + final ComposeDef compose = ComposeDef.fromJson(json); + + expect(compose.flags, isEmpty); + expect(compose.pathArgs, isEmpty); + }); + + test('parses ios-framework config correctly', () { + final Map json = { + 'requires': [ + 'ios-release', + 'ios-release-ext', + 'ios-sim-x64', + 'ios-sim-x64-ext', + 'ios-sim-arm64', + 'ios-sim-arm64-ext', + ], + 'script': 'flutter/sky/tools/create_ios_framework.py', + 'flags': ['--dsym', '--strip'], + 'path_args': { + '--arm64-out-dir': 'ios_release', + '--simulator-x64-out-dir': 'ios_debug_sim', + '--simulator-arm64-out-dir': 'ios_debug_sim_arm64', + }, + }; + + final ComposeDef compose = ComposeDef.fromJson(json); + + expect(compose.requires.length, 6); + expect(compose.script, 'flutter/sky/tools/create_ios_framework.py'); + expect(compose.flags, ['--dsym', '--strip']); + expect(compose.pathArgs.keys, contains('--arm64-out-dir')); + expect(compose.pathArgs['--arm64-out-dir'], 'ios_release'); + }); + }); +} diff --git a/shorebird/ci/shard_runner/test/config_test.dart b/shorebird/ci/shard_runner/test/config_test.dart new file mode 100644 index 0000000000000..ec3d508864758 --- /dev/null +++ b/shorebird/ci/shard_runner/test/config_test.dart @@ -0,0 +1,264 @@ +import 'package:test/test.dart'; +import 'package:shard_runner/config.dart'; + +void main() { + group('PlatformConfig', () { + test('parses single-step shard', () { + final json = { + 'android-arm64': { + 'steps': [ + { + 'type': 'gn_ninja', + 'gn_args': [ + '--android', + '--android-cpu=arm64', + '--runtime-mode=release' + ], + 'ninja_targets': ['default', 'gen_snapshot'], + 'out_dir': 'android_release_arm64', + }, + ], + }, + }; + + final config = PlatformConfig.fromJson(json); + + expect(config.shards.length, 1); + expect(config.shards.containsKey('android-arm64'), true); + + final shard = config.getShard('android-arm64'); + expect(shard.steps.length, 1); + expect(shard.steps.first, isA()); + expect(shard.artifacts, isEmpty); + + final step = shard.steps.first as GnNinjaStep; + expect(step.gnArgs, + ['--android', '--android-cpu=arm64', '--runtime-mode=release']); + expect(step.ninjaTargets, ['default', 'gen_snapshot']); + expect(step.outDir, 'android_release_arm64'); + }); + + test('parses shard with artifacts', () { + final json = { + 'android-arm64': { + 'steps': [ + { + 'type': 'gn_ninja', + 'gn_args': ['--android'], + 'ninja_targets': ['default'], + 'out_dir': 'android_release_arm64', + }, + ], + 'artifacts': [ + { + 'src': 'zip_archives/artifacts.zip', + 'dst': 'flutter_infra/\$engine/artifacts.zip' + }, + {'src': 'maven.pom', 'dst': 'maven/\$engine/maven.pom'}, + ], + }, + }; + + final config = PlatformConfig.fromJson(json); + final shard = config.getShard('android-arm64'); + + expect(shard.artifacts.length, 2); + expect(shard.artifacts[0].src, 'zip_archives/artifacts.zip'); + expect(shard.artifacts[0].dst, 'flutter_infra/\$engine/artifacts.zip'); + expect(shard.artifacts[1].src, 'maven.pom'); + }); + + test('parses multi-step shard', () { + final json = { + 'host': { + 'steps': [ + { + 'type': 'rust', + 'targets': ['aarch64-linux-android', 'x86_64-unknown-linux-gnu'], + }, + { + 'type': 'gn_ninja', + 'gn_args': ['--runtime-mode=release'], + 'ninja_targets': ['dart_sdk'], + 'out_dir': 'host_release', + }, + ], + }, + }; + + final config = PlatformConfig.fromJson(json); + final shard = config.getShard('host'); + + expect(shard.steps.length, 2); + expect(shard.steps[0], isA()); + expect(shard.steps[1], isA()); + + final rustStep = shard.steps[0] as RustStep; + expect(rustStep.targets, + ['aarch64-linux-android', 'x86_64-unknown-linux-gnu']); + + final gnStep = shard.steps[1] as GnNinjaStep; + expect(gnStep.outDir, 'host_release'); + }); + + test('parses compose_input', () { + final json = { + 'ios-release': { + 'steps': [ + { + 'type': 'gn_ninja', + 'gn_args': ['--ios', '--runtime-mode=release'], + 'ninja_targets': ['flutter_framework'], + 'out_dir': 'ios_release', + }, + ], + 'compose_input': 'ios-framework', + }, + }; + + final config = PlatformConfig.fromJson(json); + final shard = config.getShard('ios-release'); + + expect(shard.composeInput, 'ios-framework'); + }); + + test('getShard throws for unknown shard', () { + final config = PlatformConfig(shards: {}); + + expect( + () => config.getShard('nonexistent'), + throwsA(isA()), + ); + }); + }); + + group('BuildStep.fromJson', () { + test('parses gn_ninja type', () { + final json = { + 'type': 'gn_ninja', + 'gn_args': ['--android'], + 'ninja_targets': ['default'], + 'out_dir': 'out_dir', + }; + + final step = BuildStep.fromJson(json); + expect(step, isA()); + }); + + test('parses rust type', () { + final json = { + 'type': 'rust', + 'targets': ['x86_64-unknown-linux-gnu'], + }; + + final step = BuildStep.fromJson(json); + expect(step, isA()); + }); + + test('throws for unknown type', () { + final json = { + 'type': 'unknown', + }; + + expect( + () => BuildStep.fromJson(json), + throwsA(isA()), + ); + }); + }); + + group('GnNinjaStep', () { + test('fromJson parses all fields', () { + final json = { + 'type': 'gn_ninja', + 'gn_args': ['--android', '--runtime-mode=release'], + 'ninja_targets': ['default', 'gen_snapshot'], + 'out_dir': 'android_release', + }; + + final step = GnNinjaStep.fromJson(json); + + expect(step.gnArgs, ['--android', '--runtime-mode=release']); + expect(step.ninjaTargets, ['default', 'gen_snapshot']); + expect(step.outDir, 'android_release'); + }); + }); + + group('RustStep', () { + test('fromJson parses targets', () { + final json = { + 'type': 'rust', + 'targets': [ + 'aarch64-linux-android', + 'armv7-linux-androideabi', + 'x86_64-unknown-linux-gnu', + ], + }; + + final step = RustStep.fromJson(json); + + expect(step.targets, [ + 'aarch64-linux-android', + 'armv7-linux-androideabi', + 'x86_64-unknown-linux-gnu', + ]); + }); + }); + + group('ArtifactDef', () { + test('fromJson parses src and dst', () { + final json = { + 'src': 'zip_archives/artifacts.zip', + 'dst': 'flutter_infra/\$engine/artifacts.zip', + }; + + final artifact = ArtifactDef.fromJson(json); + + expect(artifact.src, 'zip_archives/artifacts.zip'); + expect(artifact.dst, 'flutter_infra/\$engine/artifacts.zip'); + expect(artifact.zip, false); + expect(artifact.contentHash, false); + }); + + test('fromJson parses zip flag', () { + final json = { + 'src': 'dart-sdk', + 'dst': 'flutter_infra/dart-sdk.zip', + 'zip': true, + }; + + final artifact = ArtifactDef.fromJson(json); + + expect(artifact.zip, true); + }); + + test('fromJson parses content_hash flag', () { + final json = { + 'src': 'dart-sdk', + 'dst': 'flutter_infra/dart-sdk.zip', + 'content_hash': true, + }; + + final artifact = ArtifactDef.fromJson(json); + + expect(artifact.contentHash, true); + }); + + test('fromJson parses all flags together', () { + final json = { + 'src': 'host_release/dart-sdk', + 'dst': 'flutter_infra/flutter/\$engine/dart-sdk-linux-x64.zip', + 'zip': true, + 'content_hash': true, + }; + + final artifact = ArtifactDef.fromJson(json); + + expect(artifact.src, 'host_release/dart-sdk'); + expect(artifact.dst, + 'flutter_infra/flutter/\$engine/dart-sdk-linux-x64.zip'); + expect(artifact.zip, true); + expect(artifact.contentHash, true); + }); + }); +} diff --git a/shorebird/ci/shard_runner/test/manifest_test.dart b/shorebird/ci/shard_runner/test/manifest_test.dart new file mode 100644 index 0000000000000..a5606ae9b2f54 --- /dev/null +++ b/shorebird/ci/shard_runner/test/manifest_test.dart @@ -0,0 +1,208 @@ +import 'dart:io'; + +import 'package:path/path.dart' as p; +import 'package:test/test.dart'; +import 'package:shard_runner/manifest.dart'; + +void main() { + group('generateManifest', () { + // Path to the ci/ directory where the template lives + // Tests run from shard_runner/, so go up one level to ci/ + final String configDir = p.normalize(p.join(Directory.current.path, '..')); + + test('generates valid YAML structure', () { + final manifest = generateManifest('abc123def456', configDir: configDir); + + expect(manifest, contains('flutter_engine_revision: abc123def456')); + expect(manifest, contains('storage_bucket: download.shorebird.dev')); + expect(manifest, contains('artifact_overrides:')); + }); + + test('includes Android release artifacts', () { + final manifest = generateManifest('test-hash', configDir: configDir); + + // Android arm64 + expect( + manifest, + contains( + r'flutter_infra_release/flutter/$engine/android-arm64-release/artifacts.zip')); + expect( + manifest, + contains( + r'flutter_infra_release/flutter/$engine/android-arm64-release/linux-x64.zip')); + expect( + manifest, + contains( + r'flutter_infra_release/flutter/$engine/android-arm64-release/darwin-x64.zip')); + expect( + manifest, + contains( + r'flutter_infra_release/flutter/$engine/android-arm64-release/windows-x64.zip')); + expect( + manifest, + contains( + r'flutter_infra_release/flutter/$engine/android-arm64-release/symbols.zip')); + + // Android arm32 + expect( + manifest, + contains( + r'flutter_infra_release/flutter/$engine/android-arm-release/artifacts.zip')); + + // Android x64 + expect( + manifest, + contains( + r'flutter_infra_release/flutter/$engine/android-x64-release/artifacts.zip')); + }); + + test('includes Dart SDK for all platforms', () { + final manifest = generateManifest('test-hash', configDir: configDir); + + expect( + manifest, + contains( + r'flutter_infra_release/flutter/$engine/dart-sdk-darwin-arm64.zip')); + expect( + manifest, + contains( + r'flutter_infra_release/flutter/$engine/dart-sdk-darwin-x64.zip')); + expect( + manifest, + contains( + r'flutter_infra_release/flutter/$engine/dart-sdk-linux-x64.zip')); + expect( + manifest, + contains( + r'flutter_infra_release/flutter/$engine/dart-sdk-windows-x64.zip')); + }); + + test('includes Maven artifacts', () { + final manifest = generateManifest('test-hash', configDir: configDir); + + // flutter_embedding_release + expect( + manifest, + contains( + r'download.flutter.io/io/flutter/flutter_embedding_release/1.0.0-$engine/flutter_embedding_release-1.0.0-$engine.pom')); + expect( + manifest, + contains( + r'download.flutter.io/io/flutter/flutter_embedding_release/1.0.0-$engine/flutter_embedding_release-1.0.0-$engine.jar')); + + // arm64_v8a_release + expect( + manifest, + contains( + r'download.flutter.io/io/flutter/arm64_v8a_release/1.0.0-$engine/arm64_v8a_release-1.0.0-$engine.pom')); + expect( + manifest, + contains( + r'download.flutter.io/io/flutter/arm64_v8a_release/1.0.0-$engine/arm64_v8a_release-1.0.0-$engine.jar')); + + // armeabi_v7a_release + expect( + manifest, + contains( + r'download.flutter.io/io/flutter/armeabi_v7a_release/1.0.0-$engine/armeabi_v7a_release-1.0.0-$engine.pom')); + + // x86_64_release + expect( + manifest, + contains( + r'download.flutter.io/io/flutter/x86_64_release/1.0.0-$engine/x86_64_release-1.0.0-$engine.pom')); + }); + + test('includes iOS release artifacts', () { + final manifest = generateManifest('test-hash', configDir: configDir); + + expect( + manifest, + contains( + r'flutter_infra_release/flutter/$engine/ios-release/artifacts.zip')); + expect( + manifest, + contains( + r'flutter_infra_release/flutter/$engine/ios-release/Flutter.framework.dSYM.zip')); + }); + + test('includes Linux release artifacts', () { + final manifest = generateManifest('test-hash', configDir: configDir); + + expect( + manifest, + contains( + r'flutter_infra_release/flutter/$engine/linux-x64/artifacts.zip')); + expect( + manifest, + contains( + r'flutter_infra_release/flutter/$engine/linux-x64-release/linux-x64-flutter-gtk.zip')); + }); + + test('includes macOS release artifacts', () { + final manifest = generateManifest('test-hash', configDir: configDir); + + expect( + manifest, + contains( + r'flutter_infra_release/flutter/$engine/darwin-x64-release/artifacts.zip')); + expect( + manifest, + contains( + r'flutter_infra_release/flutter/$engine/darwin-x64-release/framework.zip')); + expect( + manifest, + contains( + r'flutter_infra_release/flutter/$engine/darwin-x64-release/gen_snapshot.zip')); + }); + + test('includes Windows release artifacts', () { + final manifest = generateManifest('test-hash', configDir: configDir); + + expect( + manifest, + contains( + r'flutter_infra_release/flutter/$engine/windows-x64/artifacts.zip')); + expect( + manifest, + contains( + r'flutter_infra_release/flutter/$engine/windows-x64-release/windows-x64-flutter.zip')); + }); + + test('includes engine_stamp.json', () { + final manifest = generateManifest('test-hash', configDir: configDir); + + expect(manifest, + contains(r'flutter_infra_release/flutter/$engine/engine_stamp.json')); + }); + + test('includes flutter_patched_sdk_product', () { + final manifest = generateManifest('test-hash', configDir: configDir); + + expect( + manifest, + contains( + r'flutter_infra_release/flutter/$engine/flutter_patched_sdk_product.zip')); + }); + + test('uses \$engine placeholder (not hardcoded hash)', () { + final manifest = generateManifest('abc123', configDir: configDir); + + // The flutter_engine_revision should use the actual hash + expect(manifest, contains('flutter_engine_revision: abc123')); + + // But artifact paths should use $engine placeholder + expect(manifest, contains(r'$engine')); + // Should NOT contain the actual hash in artifact paths + expect(manifest.split('flutter_engine_revision:')[1], + isNot(contains('abc123/'))); + }); + + test('throws when template file not found', () { + expect( + () => generateManifest('test-hash', configDir: '/nonexistent'), + throwsA(isA()), + ); + }); + }); +} diff --git a/shorebird/ci/shards/linux.json b/shorebird/ci/shards/linux.json new file mode 100644 index 0000000000000..51b0aa86f0b6b --- /dev/null +++ b/shorebird/ci/shards/linux.json @@ -0,0 +1,104 @@ +{ + "android-arm64": { + "steps": [ + { + "type": "rust", + "targets": ["aarch64-linux-android"] + }, + { + "type": "gn_ninja", + "gn_args": ["--android", "--android-cpu=arm64", "--runtime-mode=release"], + "ninja_targets": ["default", "gen_snapshot"], + "out_dir": "android_release_arm64" + } + ], + "artifacts": [ + {"src": "zip_archives/android-arm64-release/artifacts.zip", "dst": "flutter_infra_release/flutter/$engine/android-arm64-release/artifacts.zip"}, + {"src": "zip_archives/android-arm64-release/linux-x64.zip", "dst": "flutter_infra_release/flutter/$engine/android-arm64-release/linux-x64.zip"}, + {"src": "zip_archives/android-arm64-release/symbols.zip", "dst": "flutter_infra_release/flutter/$engine/android-arm64-release/symbols.zip"}, + {"src": "arm64_v8a_release.pom", "dst": "download.flutter.io/io/flutter/arm64_v8a_release/1.0.0-$engine/arm64_v8a_release-1.0.0-$engine.pom"}, + {"src": "arm64_v8a_release.jar", "dst": "download.flutter.io/io/flutter/arm64_v8a_release/1.0.0-$engine/arm64_v8a_release-1.0.0-$engine.jar"}, + {"src": "arm64_v8a_release.maven-metadata.xml", "dst": "download.flutter.io/io/flutter/arm64_v8a_release/1.0.0-$engine/arm64_v8a_release-1.0.0-$engine.maven-metadata.xml"} + ] + }, + "android-arm32": { + "steps": [ + { + "type": "rust", + "targets": ["armv7-linux-androideabi"] + }, + { + "type": "gn_ninja", + "gn_args": ["--android", "--runtime-mode=release"], + "ninja_targets": ["default", "gen_snapshot"], + "out_dir": "android_release" + } + ], + "artifacts": [ + {"src": "zip_archives/android-arm-release/artifacts.zip", "dst": "flutter_infra_release/flutter/$engine/android-arm-release/artifacts.zip"}, + {"src": "zip_archives/android-arm-release/linux-x64.zip", "dst": "flutter_infra_release/flutter/$engine/android-arm-release/linux-x64.zip"}, + {"src": "zip_archives/android-arm-release/symbols.zip", "dst": "flutter_infra_release/flutter/$engine/android-arm-release/symbols.zip"}, + {"src": "armeabi_v7a_release.pom", "dst": "download.flutter.io/io/flutter/armeabi_v7a_release/1.0.0-$engine/armeabi_v7a_release-1.0.0-$engine.pom"}, + {"src": "armeabi_v7a_release.jar", "dst": "download.flutter.io/io/flutter/armeabi_v7a_release/1.0.0-$engine/armeabi_v7a_release-1.0.0-$engine.jar"}, + {"src": "armeabi_v7a_release.maven-metadata.xml", "dst": "download.flutter.io/io/flutter/armeabi_v7a_release/1.0.0-$engine/armeabi_v7a_release-1.0.0-$engine.maven-metadata.xml"}, + {"src": "flutter_embedding_release.pom", "dst": "download.flutter.io/io/flutter/flutter_embedding_release/1.0.0-$engine/flutter_embedding_release-1.0.0-$engine.pom"}, + {"src": "flutter_embedding_release.jar", "dst": "download.flutter.io/io/flutter/flutter_embedding_release/1.0.0-$engine/flutter_embedding_release-1.0.0-$engine.jar"}, + {"src": "flutter_embedding_release.maven-metadata.xml", "dst": "download.flutter.io/io/flutter/flutter_embedding_release/1.0.0-$engine/flutter_embedding_release-1.0.0-$engine.maven-metadata.xml"} + ] + }, + "android-x64": { + "steps": [ + { + "type": "rust", + "targets": ["x86_64-linux-android"] + }, + { + "type": "gn_ninja", + "gn_args": ["--android", "--android-cpu=x64", "--runtime-mode=release"], + "ninja_targets": ["default", "gen_snapshot"], + "out_dir": "android_release_x64" + } + ], + "artifacts": [ + {"src": "zip_archives/android-x64-release/artifacts.zip", "dst": "flutter_infra_release/flutter/$engine/android-x64-release/artifacts.zip"}, + {"src": "zip_archives/android-x64-release/linux-x64.zip", "dst": "flutter_infra_release/flutter/$engine/android-x64-release/linux-x64.zip"}, + {"src": "zip_archives/android-x64-release/symbols.zip", "dst": "flutter_infra_release/flutter/$engine/android-x64-release/symbols.zip"}, + {"src": "x86_64_release.pom", "dst": "download.flutter.io/io/flutter/x86_64_release/1.0.0-$engine/x86_64_release-1.0.0-$engine.pom"}, + {"src": "x86_64_release.jar", "dst": "download.flutter.io/io/flutter/x86_64_release/1.0.0-$engine/x86_64_release-1.0.0-$engine.jar"}, + {"src": "x86_64_release.maven-metadata.xml", "dst": "download.flutter.io/io/flutter/x86_64_release/1.0.0-$engine/x86_64_release-1.0.0-$engine.maven-metadata.xml"} + ] + }, + "host": { + "steps": [ + { + "type": "rust", + "targets": [ + "armv7-linux-androideabi", + "aarch64-linux-android", + "i686-linux-android", + "x86_64-linux-android", + "x86_64-unknown-linux-gnu" + ] + }, + { + "type": "gn_ninja", + "gn_args": ["--runtime-mode=release", "--no-prebuilt-dart-sdk"], + "ninja_targets": ["dart_sdk", "flutter/shell/platform/linux:flutter_gtk", "flutter/build/archives:flutter_patched_sdk"], + "out_dir": "host_release" + }, + { + "type": "gn_ninja", + "gn_args": ["--no-prebuilt-dart-sdk"], + "ninja_targets": ["flutter/build/archives:artifacts"], + "out_dir": "host_debug" + } + ], + "artifacts": [ + {"src": "host_release/dart-sdk", "dst": "flutter_infra_release/flutter/$engine/dart-sdk-linux-x64.zip", "zip": true, "content_hash": true}, + {"src": "host_release/zip_archives/flutter_patched_sdk_product.zip", "dst": "flutter_infra_release/flutter/$engine/flutter_patched_sdk_product.zip"}, + {"src": "host_release/zip_archives/linux-x64-release/linux-x64-flutter-gtk.zip", "dst": "flutter_infra_release/flutter/$engine/linux-x64-release/linux-x64-flutter-gtk.zip"}, + {"src": "host_debug/zip_archives/linux-x64/artifacts.zip", "dst": "flutter_infra_release/flutter/$engine/linux-x64/artifacts.zip"}, + {"src": "host_release/aot_tools/aot-tools.dill", "dst": "shorebird/$engine/aot-tools.dill"} + ] + } +} diff --git a/shorebird/ci/shards/macos.json b/shorebird/ci/shards/macos.json new file mode 100644 index 0000000000000..db68dc3e1a2f8 --- /dev/null +++ b/shorebird/ci/shards/macos.json @@ -0,0 +1,169 @@ +{ + "android": { + "steps": [ + { + "type": "rust", + "targets": [ + "aarch64-apple-ios", + "x86_64-apple-ios", + "aarch64-apple-darwin", + "x86_64-apple-darwin" + ] + }, + { + "type": "gn_ninja", + "gn_args": ["--android", "--android-cpu=arm64", "--runtime-mode=release", "--gn-args=host_cpu=\"x64\""], + "ninja_targets": ["flutter/shell/platform/android:gen_snapshot"], + "out_dir": "android_release_arm64" + }, + { + "type": "gn_ninja", + "gn_args": ["--android", "--runtime-mode=release", "--gn-args=host_cpu=\"x64\""], + "ninja_targets": ["flutter/shell/platform/android:gen_snapshot"], + "out_dir": "android_release" + }, + { + "type": "gn_ninja", + "gn_args": ["--android", "--android-cpu=x64", "--runtime-mode=release", "--gn-args=host_cpu=\"x64\""], + "ninja_targets": ["flutter/shell/platform/android:gen_snapshot"], + "out_dir": "android_release_x64" + } + ], + "artifacts": [ + {"src": "android_release_arm64/zip_archives/android-arm64-release/darwin-x64.zip", "dst": "flutter_infra_release/flutter/$engine/android-arm64-release/darwin-x64.zip"}, + {"src": "android_release/zip_archives/android-arm-release/darwin-x64.zip", "dst": "flutter_infra_release/flutter/$engine/android-arm-release/darwin-x64.zip"}, + {"src": "android_release_x64/zip_archives/android-x64-release/darwin-x64.zip", "dst": "flutter_infra_release/flutter/$engine/android-x64-release/darwin-x64.zip"} + ] + }, + "ios-release": { + "steps": [ + { + "type": "rust", + "targets": ["aarch64-apple-ios"] + }, + { + "type": "gn_ninja", + "gn_args": ["--ios", "--runtime-mode=release", "--gn-arg=shorebird_runtime=true"], + "ninja_targets": ["flutter/shell/platform/darwin/ios:flutter_framework", "flutter/lib/snapshot:generate_snapshot_bins"], + "out_dir": "ios_release" + } + ], + "compose_input": "ios-framework" + }, + "ios-release-ext": { + "steps": [ + { + "type": "rust", + "targets": ["aarch64-apple-ios"] + }, + { + "type": "gn_ninja", + "gn_args": ["--ios", "--runtime-mode=release", "--darwin-extension-safe", "--xcode-symlinks", "--gn-arg=shorebird_runtime=true"], + "ninja_targets": ["flutter/shell/platform/darwin/ios:flutter_framework", "flutter/lib/snapshot:generate_snapshot_bins"], + "out_dir": "ios_release_extension_safe" + } + ], + "compose_input": "ios-framework" + }, + "ios-sim-x64": { + "steps": [ + { + "type": "rust", + "targets": ["x86_64-apple-ios"] + }, + { + "type": "gn_ninja", + "gn_args": ["--ios", "--runtime-mode=debug", "--simulator"], + "ninja_targets": ["flutter/shell/platform/darwin/ios:flutter_framework", "flutter/lib/snapshot:generate_snapshot_bins"], + "out_dir": "ios_debug_sim" + } + ], + "compose_input": "ios-framework" + }, + "ios-sim-x64-ext": { + "steps": [ + { + "type": "rust", + "targets": ["x86_64-apple-ios"] + }, + { + "type": "gn_ninja", + "gn_args": ["--ios", "--runtime-mode=debug", "--darwin-extension-safe", "--simulator"], + "ninja_targets": ["flutter/shell/platform/darwin/ios:flutter_framework", "flutter/lib/snapshot:generate_snapshot_bins"], + "out_dir": "ios_debug_sim_extension_safe" + } + ], + "compose_input": "ios-framework" + }, + "ios-sim-arm64": { + "steps": [ + { + "type": "gn_ninja", + "gn_args": ["--ios", "--runtime-mode=debug", "--simulator", "--simulator-cpu=arm64"], + "ninja_targets": ["flutter/shell/platform/darwin/ios:flutter_framework", "flutter/lib/snapshot:generate_snapshot_bins"], + "out_dir": "ios_debug_sim_arm64" + } + ], + "compose_input": "ios-framework" + }, + "ios-sim-arm64-ext": { + "steps": [ + { + "type": "gn_ninja", + "gn_args": ["--ios", "--runtime-mode=debug", "--darwin-extension-safe", "--simulator", "--simulator-cpu=arm64"], + "ninja_targets": ["flutter/shell/platform/darwin/ios:flutter_framework", "flutter/lib/snapshot:generate_snapshot_bins"], + "out_dir": "ios_debug_sim_arm64_extension_safe" + } + ], + "compose_input": "ios-framework" + }, + "mac-arm64": { + "steps": [ + { + "type": "rust", + "targets": ["aarch64-apple-darwin"] + }, + { + "type": "gn_ninja", + "gn_args": ["--runtime-mode=release", "--mac-cpu=arm64", "--no-prebuilt-dart-sdk"], + "ninja_targets": ["dart_sdk"], + "out_dir": "host_release_arm64" + }, + { + "type": "gn_ninja", + "gn_args": ["--runtime-mode=release", "--mac", "--mac-cpu=arm64"], + "ninja_targets": ["flutter/shell/platform/darwin/macos:zip_macos_flutter_framework", "flutter/lib/snapshot:generate_snapshot_bins", "flutter/build/archives:artifacts"], + "out_dir": "mac_release_arm64" + } + ], + "compose_input": "macos-framework", + "artifacts": [ + {"src": "host_release_arm64/dart-sdk", "dst": "flutter_infra_release/flutter/$engine/dart-sdk-darwin-arm64.zip", "zip": true, "content_hash": true} + ] + }, + "mac-x64": { + "steps": [ + { + "type": "rust", + "targets": ["x86_64-apple-darwin"] + }, + { + "type": "gn_ninja", + "gn_args": ["--runtime-mode=release", "--mac-cpu=x64", "--no-prebuilt-dart-sdk"], + "ninja_targets": ["dart_sdk"], + "out_dir": "host_release" + }, + { + "type": "gn_ninja", + "gn_args": ["--runtime-mode=release", "--mac", "--mac-cpu=x64"], + "ninja_targets": ["flutter/shell/platform/darwin/macos:zip_macos_flutter_framework", "flutter/lib/snapshot:generate_snapshot_bins", "flutter/build/archives:artifacts"], + "out_dir": "mac_release" + } + ], + "compose_input": "macos-framework", + "artifacts": [ + {"src": "host_release/dart-sdk", "dst": "flutter_infra_release/flutter/$engine/dart-sdk-darwin-x64.zip", "zip": true, "content_hash": true}, + {"src": "engine_stamp.json", "dst": "flutter_infra_release/flutter/$engine/engine_stamp.json"} + ] + } +} diff --git a/shorebird/ci/shards/windows.json b/shorebird/ci/shards/windows.json new file mode 100644 index 0000000000000..5775e5d0a1ad0 --- /dev/null +++ b/shorebird/ci/shards/windows.json @@ -0,0 +1,66 @@ +{ + "android-arm64": { + "steps": [ + { + "type": "gn_ninja", + "gn_args": ["--android", "--android-cpu=arm64", "--runtime-mode=release"], + "ninja_targets": ["archive_win_gen_snapshot"], + "out_dir": "android_release_arm64" + } + ], + "artifacts": [ + {"src": "zip_archives/android-arm64-release/windows-x64.zip", "dst": "flutter_infra_release/flutter/$engine/android-arm64-release/windows-x64.zip"} + ] + }, + "android-arm32": { + "steps": [ + { + "type": "gn_ninja", + "gn_args": ["--android", "--runtime-mode=release"], + "ninja_targets": ["archive_win_gen_snapshot"], + "out_dir": "android_release" + } + ], + "artifacts": [ + {"src": "zip_archives/android-arm-release/windows-x64.zip", "dst": "flutter_infra_release/flutter/$engine/android-arm-release/windows-x64.zip"} + ] + }, + "android-x64": { + "steps": [ + { + "type": "gn_ninja", + "gn_args": ["--android", "--android-cpu=x64", "--runtime-mode=release"], + "ninja_targets": ["archive_win_gen_snapshot"], + "out_dir": "android_release_x64" + } + ], + "artifacts": [ + {"src": "zip_archives/android-x64-release/windows-x64.zip", "dst": "flutter_infra_release/flutter/$engine/android-x64-release/windows-x64.zip"} + ] + }, + "host": { + "steps": [ + { + "type": "rust", + "targets": ["x86_64-pc-windows-msvc"] + }, + { + "type": "gn_ninja", + "gn_args": ["--runtime-mode=release", "--no-prebuilt-dart-sdk"], + "ninja_targets": ["dart_sdk", "flutter/build/archives:windows_flutter", "gen_snapshot", "windows", "flutter/build/archives:artifacts"], + "out_dir": "host_release" + }, + { + "type": "gn_ninja", + "gn_args": ["--no-prebuilt-dart-sdk"], + "ninja_targets": ["flutter/build/archives:artifacts"], + "out_dir": "host_debug" + } + ], + "artifacts": [ + {"src": "host_release/dart-sdk", "dst": "flutter_infra_release/flutter/$engine/dart-sdk-windows-x64.zip", "zip": true, "content_hash": true}, + {"src": "host_release/zip_archives/windows-x64-release/windows-x64-flutter.zip", "dst": "flutter_infra_release/flutter/$engine/windows-x64-release/windows-x64-flutter.zip"}, + {"src": "host_debug/zip_archives/windows-x64/artifacts.zip", "dst": "flutter_infra_release/flutter/$engine/windows-x64/artifacts.zip"} + ] + } +} diff --git a/shorebird/ci/win_build_and_upload.sh b/shorebird/ci/win_build_and_upload.sh new file mode 100755 index 0000000000000..bbda403ff2c67 --- /dev/null +++ b/shorebird/ci/win_build_and_upload.sh @@ -0,0 +1,32 @@ +#!/bin/bash -e + +# Usage: +# ./win_build_and_upload.sh flutter_root engine_hash +# +# This is the main entrypoint for building and uploading Windows engine artifacts. +# It is called from the _build_engine repository's CI scripts. + +if [ "$#" -ne 2 ]; then + echo "Usage: $0 flutter_root engine_hash" + exit 1 +fi + +FLUTTER_ROOT=$1 +ENGINE_HASH=$2 +ENGINE_ROOT=$FLUTTER_ROOT/engine + +# Get the absolute path to the directory of this script. +SCRIPT_DIR=$(cd $(dirname $0) && pwd) + +echo "Building engine at $ENGINE_ROOT and uploading to gs://download.shorebird.dev" + +cd $SCRIPT_DIR + +# Run the setup script. +./internal/win_setup.sh + +# Then run the build. +./internal/win_build.sh $ENGINE_ROOT + +# Copy Shorebird engine artifacts to Google Cloud Storage. +./internal/win_upload.sh $ENGINE_ROOT $ENGINE_HASH diff --git a/shorebird/docs/BUILDING.md b/shorebird/docs/BUILDING.md new file mode 100644 index 0000000000000..dfd3c655a1324 --- /dev/null +++ b/shorebird/docs/BUILDING.md @@ -0,0 +1,44 @@ +# Building the Shorebird Engine Locally + +This document explains how to build the Shorebird engine locally for different platforms. + +All commands assume you are running from the `engine/src` directory. + +## Prerequisites + +- Follow the standard Flutter engine setup instructions +- Ensure you have the necessary toolchains installed for your target platform + +## macOS + +### iOS (arm64) + +```bash +./flutter/tools/gn --no-rbe --no-enable-unittests --runtime-mode=release --ios --gn-arg='shorebird_runtime=true' +ninja -C out/ios_release flutter/shell/platform/darwin/ios:flutter_framework flutter/lib/snapshot:generate_snapshot_bins +``` + +### Android arm64 + +```bash +./flutter/tools/gn --no-rbe --no-enable-unittests --android --android-cpu=arm64 --runtime-mode=release --gn-args='host_cpu="x64"' +ninja -C out/android_release_arm64 flutter/shell/platform/android:gen_snapshot +``` + +## Windows + +### Android arm64 + +```bash +./flutter/tools/gn --no-rbe --no-enable-unittests --android --android-cpu=arm64 --runtime-mode=release +ninja -C out/android_release_arm64 archive_win_gen_snapshot +``` + +## Linux + +### Android arm64 + +```bash +./flutter/tools/gn --no-rbe --no-enable-unittests --android --android-cpu=arm64 --runtime-mode=release +ninja -C out/android_release_arm64 default gen_snapshot +```