├── .devcontainer ├── Dockerfile └── devcontainer.json ├── .dockerignore ├── .github └── workflows │ ├── build-app.yml │ └── flutter.yml ├── .gitignore ├── .vscode └── settings.json ├── CHANGELOG.md ├── LICENSE ├── README.md ├── analysis_options.yaml ├── bin └── flutterpi_tool.dart ├── cliff.toml ├── lib ├── flutterpi_tool.dart └── src │ ├── application_package_factory.dart │ ├── archive.dart │ ├── archive │ ├── lzma │ │ ├── lzma_decoder.dart │ │ └── range_decoder.dart │ └── xz_decoder.dart │ ├── authenticating_artifact_updater.dart │ ├── build_system │ ├── build_app.dart │ ├── extended_environment.dart │ └── targets.dart │ ├── cache.dart │ ├── cli │ ├── command_runner.dart │ ├── commands │ │ ├── build.dart │ │ ├── devices.dart │ │ ├── precache.dart │ │ ├── run.dart │ │ └── test.dart │ └── flutterpi_command.dart │ ├── common.dart │ ├── config.dart │ ├── devices │ ├── device_manager.dart │ └── flutterpi_ssh │ │ ├── device.dart │ │ ├── device_discovery.dart │ │ └── ssh_utils.dart │ ├── executable.dart │ ├── fltool │ ├── common.dart │ ├── context_runner.dart │ └── globals.dart │ ├── github.dart │ ├── more_os_utils.dart │ └── shutdown_hooks.dart ├── pubspec.yaml └── test ├── build_bundle_test.dart ├── cache_test.dart ├── fake_github.dart ├── github_test.dart ├── github_test_api_output.dart └── src └── fake_process_manager.dart /.devcontainer/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM mcr.microsoft.com/devcontainers/base:debian 2 | 3 | # Install needed packages 4 | RUN apt-get update && apt-get install -y curl git unzip xz-utils zip 5 | 6 | USER 1000:1000 7 | 8 | ARG FLUTTER_VERSION=3.22.1 9 | RUN git clone -b $FLUTTER_VERSION https://github.com/flutter/flutter.git /home/vscode/flutter 10 | 11 | ENV PATH /home/vscode/flutter/bin:/home/vscode/.pub-cache/bin:$PATH 12 | 13 | RUN flutter precache 14 | 15 | USER root 16 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | // For format details, see https://aka.ms/devcontainer.json. For config options, see the 2 | // README at: https://github.com/devcontainers/templates/tree/main/src/debian 3 | { 4 | "name": "Debian", 5 | 6 | // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile 7 | "build": { 8 | // Path is relative to the devcontainer.json file. 9 | "dockerfile": "Dockerfile" 10 | }, 11 | 12 | // Features to add to the dev container. More info: https://containers.dev/features. 13 | // "features": {}, 14 | 15 | // Use 'forwardPorts' to make a list of ports inside the container available locally. 16 | // "forwardPorts": [], 17 | 18 | // Configure tool-specific properties. 19 | // "customizations": {}, 20 | 21 | "customizations": { 22 | "vscode": { 23 | "extensions": [ 24 | "Dart-Code.flutter" 25 | ] 26 | } 27 | }, 28 | 29 | // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. 30 | // "remoteUser": "root" 31 | 32 | "postStartCommand": "flutter pub global activate -spath /workspaces/flutterpi_tool" 33 | } 34 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # See https://www.dartlang.org/guides/libraries/private-files 2 | 3 | # Files and directories created by pub 4 | .dart_tool/ 5 | .packages 6 | build/ 7 | # If you're building an application, you may want to check-in your pubspec.lock 8 | pubspec.lock 9 | 10 | # Directory created by dartdoc 11 | # If you don't generate documentation locally you can remove this line. 12 | doc/api/ 13 | 14 | # dotenv environment variables file 15 | .env* 16 | 17 | # Avoid committing generated Javascript files: 18 | *.dart.js 19 | *.info.json # Produced by the --dump-info flag. 20 | *.js # When generated by dart2js. Don't specify *.js if your 21 | # project includes source files written in JavaScript. 22 | *.js_ 23 | *.js.deps 24 | *.js.map 25 | 26 | .flutter-plugins 27 | .flutter-plugins-dependencies 28 | 29 | # MacOS ignores 30 | # General 31 | .DS_Store 32 | .AppleDouble 33 | .LSOverride 34 | 35 | # Icon must end with two \r 36 | Icon 37 | 38 | # Thumbnails 39 | ._* 40 | 41 | # Files that might appear in the root of a volume 42 | .DocumentRevisions-V100 43 | .fseventsd 44 | .Spotlight-V100 45 | .TemporaryItems 46 | .Trashes 47 | .VolumeIcon.icns 48 | .com.apple.timemachine.donotpresent 49 | 50 | # Directories potentially created on remote AFP share 51 | .AppleDB 52 | .AppleDesktop 53 | Network Trash Folder 54 | Temporary Items 55 | .apdisk 56 | -------------------------------------------------------------------------------- /.github/workflows/build-app.yml: -------------------------------------------------------------------------------- 1 | name: Build Test App 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | pull_request: 7 | branches: [ "main" ] 8 | 9 | jobs: 10 | build: 11 | name: Build Flutter-Pi Bundle (${{ matrix.arch }}, ${{ matrix.cpu}}) 12 | runs-on: ubuntu-latest 13 | strategy: 14 | matrix: 15 | arch: 16 | - arm 17 | - arm64 18 | - x64 19 | - riscv64 20 | cpu: 21 | - generic 22 | include: 23 | - arch: arm 24 | cpu: pi3 25 | - arch: arm 26 | cpu: pi4 27 | - arch: arm64 28 | cpu: pi3 29 | - arch: arm64 30 | cpu: pi4 31 | steps: 32 | - uses: actions/checkout@v4 33 | 34 | - uses: subosito/flutter-action@v2 35 | with: 36 | cache: true 37 | channel: stable 38 | flutter-version: 3.29.x 39 | 40 | - name: Install dependencies & Activate as global executable 41 | run: | 42 | flutter pub get 43 | flutter pub global activate -spath . 44 | 45 | - name: Create test app 46 | run: flutter create test_app 47 | 48 | - name: Run flutterpi_tool build 49 | working-directory: test_app 50 | run: | 51 | echo '::group::flutterpi_tool build ... --debug-unoptimized' 52 | flutterpi_tool build --arch=${{ matrix.arch }} --cpu=${{ matrix.cpu }} --debug-unoptimized --debug-symbols 53 | echo '::endgroup::' 54 | 55 | echo '::group::flutterpi_tool build ... --debug' 56 | flutterpi_tool build --arch=${{ matrix.arch }} --cpu=${{ matrix.cpu }} --debug --debug-symbols 57 | echo '::endgroup::' 58 | 59 | echo '::group::flutterpi_tool build ... --profile' 60 | flutterpi_tool build --arch=${{ matrix.arch }} --cpu=${{ matrix.cpu }} --profile --debug-symbols 61 | echo '::endgroup::' 62 | 63 | echo '::group::flutterpi_tool build ... --release' 64 | flutterpi_tool build --arch=${{ matrix.arch }} --cpu=${{ matrix.cpu }} --release --debug-symbols 65 | echo '::endgroup::' 66 | -------------------------------------------------------------------------------- /.github/workflows/flutter.yml: -------------------------------------------------------------------------------- 1 | # This workflow uses actions that are not certified by GitHub. 2 | # They are provided by a third-party and are governed by 3 | # separate terms of service, privacy policy, and support 4 | # documentation. 5 | 6 | name: Flutter 7 | 8 | on: 9 | push: 10 | branches: [ "main" ] 11 | pull_request: 12 | branches: [ "main" ] 13 | 14 | jobs: 15 | build: 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: actions/checkout@v4 19 | 20 | - uses: subosito/flutter-action@v2 21 | with: 22 | cache: true 23 | channel: stable 24 | flutter-version: 3.29.x 25 | 26 | - name: Install dependencies 27 | run: flutter pub get 28 | 29 | - name: Verify formatting 30 | run: dart format --output=none --set-exit-if-changed . 31 | 32 | # Consider passing '--fatal-infos' for slightly stricter analysis. 33 | - name: Analyze project source 34 | run: flutter analyze 35 | 36 | # Your project will need to have tests in test/ and a dependency on 37 | # package:test for this step to succeed. Note that Flutter projects will 38 | # want to change this to 'flutter test'. 39 | - name: Run tests 40 | run: flutter test 41 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://www.dartlang.org/guides/libraries/private-files 2 | 3 | # Files and directories created by pub 4 | .dart_tool/ 5 | .packages 6 | build/ 7 | # If you're building an application, you may want to check-in your pubspec.lock 8 | pubspec.lock 9 | 10 | # Directory created by dartdoc 11 | # If you don't generate documentation locally you can remove this line. 12 | doc/api/ 13 | 14 | # dotenv environment variables file 15 | .env* 16 | 17 | # Avoid committing generated Javascript files: 18 | *.dart.js 19 | *.info.json # Produced by the --dump-info flag. 20 | *.js # When generated by dart2js. Don't specify *.js if your 21 | # project includes source files written in JavaScript. 22 | *.js_ 23 | *.js.deps 24 | *.js.map 25 | 26 | .flutter-plugins 27 | .flutter-plugins-dependencies 28 | 29 | # MacOS ignores 30 | # General 31 | .DS_Store 32 | .AppleDouble 33 | .LSOverride 34 | 35 | # Icon must end with two \r 36 | Icon 37 | 38 | # Thumbnails 39 | ._* 40 | 41 | # Files that might appear in the root of a volume 42 | .DocumentRevisions-V100 43 | .fseventsd 44 | .Spotlight-V100 45 | .TemporaryItems 46 | .Trashes 47 | .VolumeIcon.icns 48 | .com.apple.timemachine.donotpresent 49 | 50 | # Directories potentially created on remote AFP share 51 | .AppleDB 52 | .AppleDesktop 53 | Network Trash Folder 54 | Temporary Items 55 | .apdisk -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "dart.lineLength": 80, 3 | "editor.rulers": [ 4 | 80, 5 | ] 6 | } -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 0.7.3 - 2025-04-29 2 | - add `flutterpi_tool test` subcommand 3 | - supports running integration tests on registered devices, e.g. 4 | - `flutterpi_tool test integration_test -d pi` 5 | - add `--dummy-display` and `--dummy-display-size` args for `flutterpi_tool devices add` 6 | - allows simulating a display, useful if no real display is attached 7 | 8 | ## 0.7.2 - 2025-04-29 9 | - add `flutterpi_tool test` subcommand 10 | - supports running integration tests on registered devices, e.g. 11 | - `flutterpi_tool test integration_test -d pi` 12 | - add `--dummy-display` and `--dummy-display-size` args for `flutterpi_tool devices add` 13 | - allows simulating a display, useful if no real display is attached 14 | 15 | ## 0.7.1 - 2025-03-21 16 | - fix missing executable permissions when running from windows 17 | - fix app not terminating when running from windows 18 | 19 | ## 0.7.0 - 2025-03-20 20 | - flutter 3.29 compatibility 21 | 22 | ## [0.6.0] - 2024-01-13 23 | - fix "artifact may not be available in some environments" warnings 24 | - 3.27 compatibility 25 | 26 | ## [0.5.4] - 2024-08-13 27 | - fix `flutterpi_tool run -d` command for flutter 3.24 28 | 29 | ## [0.5.3] - 2024-08-13 30 | - Fix artifact finding when github API results are paginated 31 | 32 | ## [0.5.2] - 2024-08-09 33 | - Flutter 3.24 compatibility 34 | - Print a nicer error message if engine artifacts are not yet available 35 | 36 | ## [0.5.1] - 2024-08-08 37 | - Expand remote user permissions check to `render` group, since that's necessary as well to use the hardware GPU. 38 | - Added a workaround for an issue where the executable permission of certain files would be lost when copying them to the output directory, causing errors when trying to run the app on the target. 39 | - Reduced the amount of GitHub API traffic generated when checking for updates to flutter-pi, to avoid rate limiting. 40 | - Changed the severity of the `failed to check for flutter-pi updates` message to a warning to avoid confusion. 41 | 42 | ## [0.5.0] - 2024-06-26 43 | 44 | - add `run` and `devices` subcommands 45 | - add persistent flutterpi_tool config for devices 46 | - update Readme 47 | - constrain to flutter 3.22.0 48 | 49 | ## [0.4.1] - 2024-06-15 50 | 51 | ### 📚 Documentation 52 | 53 | - Mention version conflicts in README 54 | 55 | ## 0.4.0 56 | 57 | - fix for flutter 3.22 58 | 59 | ## 0.3.0 60 | 61 | - fix for flutter 3.19 62 | 63 | ## 0.2.1 64 | 65 | - fix gen_snapshot selection 66 | 67 | ## 0.2.0 68 | 69 | - add macos host support 70 | - add `--dart-define`, `--dart-define-from-file`, `--target` flags 71 | - add `--debug-symbols` flag 72 | - add support for v2 artifact layout 73 | 74 | ## 0.1.2 75 | 76 | - update `flutterpi_tool --help` in readme 77 | 78 | ## 0.1.1 79 | 80 | - update `flutterpi_tool build help` in readme 81 | 82 | ## 0.1.0 83 | 84 | - add x64 support with `--arch=x64` (and `--cpu=generic`) 85 | - fix stale `app.so` when switching architectures (or cpus) 86 | - fix `--tree-shake-icons` defaults 87 | - fix inconsistent cached artifact versions 88 | 89 | ## 0.0.5 90 | 91 | - add `precache` command 92 | 93 | ## 0.0.4 94 | 95 | - update readme for new build option 96 | 97 | ## 0.0.3 98 | 99 | - remove some logging 100 | - add `--[no-]-tree-shake-icons` flag 101 | - sometimes tree shaking is impossible, in which case 102 | it's necessary to specify `--no-tree-shake-icons`, otherwise 103 | the tool will error 104 | 105 | ## 0.0.2 106 | 107 | - rename global executable `flutterpi-tool ==> flutterpi_tool` 108 | 109 | ## 0.0.1 110 | 111 | - Initial version. 112 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Hannes Winkler 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # flutterpi_tool 2 | A tool to make developing & distributing flutter apps for https://github.com/ardera/flutter-pi easier. 3 | 4 | ## 📰 News 5 | - flutterpi_tool now supports running apps on devices directly. 6 | - Windows and Linux armv7/arm64 are now supported for running flutterpi_tool. 7 | 8 | ## Setup 9 | Setting up is as simple as: 10 | ```shell 11 | flutter pub global activate flutterpi_tool 12 | ``` 13 | 14 | `flutterpi_tool` is pretty deeply integrated with the official flutter tool, so it's very well possible you encounter errors during this step when using incompatible versions. 15 | 16 | If that happens, and `flutter pub global activate` exits with an error, make sure you're on the latest stable flutter SDK. If you're on an older flutter SDK, you might want to add an explicit dependency constraint to use an older version of flutterpi_tool. E.g. for flutter 3.19 you would use flutterpi_tool 0.3.x: 17 | 18 | ```shell 19 | flutter pub global activate flutterpi_tool ^0.3.0 20 | ``` 21 | 22 | If you are already using the latest stable flutter SDK, and the command still doesn't work, please open an issue! 23 | 24 | ## Usage 25 | ```console 26 | $ flutterpi_tool --help 27 | A tool to make development & distribution of flutter-pi apps easier. 28 | 29 | Usage: flutterpi_tool [arguments] 30 | 31 | Global options: 32 | -h, --help Print this usage information. 33 | -d, --device-id Target device id or name (prefixes allowed). 34 | 35 | Other options 36 | --verbose Enable verbose logging. 37 | 38 | Available commands: 39 | 40 | Flutter-Pi Tool 41 | precache Populate the flutterpi_tool's cache of binary artifacts. 42 | 43 | Project 44 | build Builds a flutter-pi asset bundle. 45 | run Run your Flutter app on an attached device. 46 | 47 | Tools & Devices 48 | devices List & manage flutterpi_tool devices. 49 | 50 | Run "flutterpi_tool help " for more information about a command. 51 | ``` 52 | 53 | ## Examples 54 | ### 1. Adding a device 55 | ```console 56 | $ flutterpi_tool devices add pi@pi5 57 | Device "pi5" has been added successfully. 58 | ``` 59 | 60 | ### 2. Adding a device with an explicit display size of 285x190mm, and a custom device name 61 | ```console 62 | $ flutterpi_tool devices add pi@pi5 --display-size=285x190 --id=my-pi 63 | Device "my-pi" has been added successfully. 64 | ``` 65 | 66 | ### 3. Listing devices 67 | ```console 68 | $ flutterpi_tool devices 69 | Found 1 wirelessly connected device: 70 | pi5 (mobile) • pi5 • linux-arm64 • Linux 71 | 72 | If you expected another device to be detected, try increasing the time to wait 73 | for connected devices by using the "flutterpi_tool devices list" command with 74 | the "--device-timeout" flag. 75 | ... 76 | ``` 77 | 78 | ### 4. Creating and running an app on a remote device 79 | ```console 80 | $ flutter create hello_world && cd hello_world 81 | 82 | $ flutterpi_tool run -d pi5 83 | Launching lib/main.dart on pi5 in debug mode... 84 | Building Flutter-Pi bundle... 85 | Installing app on device... 86 | ... 87 | ``` 88 | 89 | ### 5. Running the same app in profile mode 90 | ``` 91 | $ flutterpi_tool run -d pi5 --profile 92 | ``` 93 | -------------------------------------------------------------------------------- /analysis_options.yaml: -------------------------------------------------------------------------------- 1 | # This file configures the static analysis results for your project (errors, 2 | # warnings, and lints). 3 | # 4 | # This enables the 'recommended' set of lints from `package:lints`. 5 | # This set helps identify many issues that may lead to problems when running 6 | # or consuming Dart code, and enforces writing Dart using a single, idiomatic 7 | # style and format. 8 | # 9 | # If you want a smaller set of lints you can change this to specify 10 | # 'package:lints/core.yaml'. These are just the most critical lints 11 | # (the recommended set includes the core lints). 12 | # The core lints are also what is used by pub.dev for scoring packages. 13 | 14 | include: package:lints/recommended.yaml 15 | 16 | linter: 17 | rules: 18 | - require_trailing_commas 19 | 20 | analyzer: 21 | exclude: 22 | - lib/src/archive 23 | 24 | # For more information about the core and recommended set of lints, see 25 | # https://dart.dev/go/core-lints 26 | 27 | # For additional information about configuring this file, see 28 | # https://dart.dev/guides/language/analysis-options 29 | -------------------------------------------------------------------------------- /bin/flutterpi_tool.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutterpi_tool/src/executable.dart' as executable; 2 | 3 | Future main(List arguments) async { 4 | await executable.main(arguments); 5 | } 6 | -------------------------------------------------------------------------------- /cliff.toml: -------------------------------------------------------------------------------- 1 | # git-cliff ~ default configuration file 2 | # https://git-cliff.org/docs/configuration 3 | # 4 | # Lines starting with "#" are comments. 5 | # Configuration options are organized into tables and keys. 6 | # See documentation for more information on available options. 7 | 8 | [changelog] 9 | # changelog header 10 | header = "" 11 | # template for the changelog body 12 | # https://keats.github.io/tera/docs/#introduction 13 | body = """ 14 | {% if version %}\ 15 | ## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }} 16 | {% else %}\ 17 | ## [unreleased] 18 | {% endif %}\ 19 | {% for group, commits in commits | group_by(attribute="group") %} 20 | ### {{ group | striptags | trim | upper_first }} 21 | {% for commit in commits %} 22 | - {% if commit.scope %}*({{ commit.scope }})* {% endif %}\ 23 | {% if commit.breaking %}[**breaking**] {% endif %}\ 24 | {{ commit.message | upper_first }}\ 25 | {% endfor %} 26 | {% endfor %}\n 27 | """ 28 | # template for the changelog footer 29 | footer = """ 30 | 31 | """ 32 | # remove the leading and trailing s 33 | trim = true 34 | # postprocessors 35 | postprocessors = [ 36 | # { pattern = '', replace = "https://github.com/orhun/git-cliff" }, # replace repository URL 37 | ] 38 | 39 | [git] 40 | # parse the commits based on https://www.conventionalcommits.org 41 | conventional_commits = true 42 | # filter out the commits that are not conventional 43 | filter_unconventional = true 44 | # process each line of a commit as an individual commit 45 | split_commits = false 46 | # regex for preprocessing the commit messages 47 | commit_preprocessors = [ 48 | # Replace issue numbers 49 | #{ pattern = '\((\w+\s)?#([0-9]+)\)', replace = "([#${2}](/issues/${2}))"}, 50 | # Check spelling of the commit with https://github.com/crate-ci/typos 51 | # If the spelling is incorrect, it will be automatically fixed. 52 | #{ pattern = '.*', replace_command = 'typos --write-changes -' }, 53 | ] 54 | # regex for parsing and grouping commits 55 | commit_parsers = [ 56 | { message = "^feat", group = "🚀 Features" }, 57 | { message = "^fix", group = "🐛 Bug Fixes" }, 58 | { message = "^doc", group = "📚 Documentation" }, 59 | { message = "^perf", group = "⚡ Performance" }, 60 | { message = "^refactor", group = "🚜 Refactor" }, 61 | { message = "^style", group = "🎨 Styling" }, 62 | { message = "^test", group = "🧪 Testing" }, 63 | { message = "^chore\\(release\\): prepare for", skip = true }, 64 | { message = "^chore\\(release\\): release", skip = true }, 65 | { message = "^chore\\(deps.*\\)", skip = true }, 66 | { message = "^chore\\(pr\\)", skip = true }, 67 | { message = "^chore\\(pull\\)", skip = true }, 68 | { message = "^chore|^ci", group = "⚙️ Miscellaneous Tasks" }, 69 | { body = ".*security", group = "🛡️ Security" }, 70 | { message = "^revert", group = "◀️ Revert" }, 71 | ] 72 | # protect breaking changes from being skipped due to matching a skipping commit_parser 73 | protect_breaking_commits = false 74 | # filter out the commits that are not matched by commit parsers 75 | filter_commits = false 76 | # regex for matching git tags 77 | # tag_pattern = "v[0-9].*" 78 | # regex for skipping tags 79 | # skip_tags = "" 80 | # regex for ignoring tags 81 | # ignore_tags = "" 82 | # sort the tags topologically 83 | topo_order = false 84 | # sort the commits inside sections by oldest/newest order 85 | sort_commits = "oldest" 86 | # limit the number of commits included in the changelog. 87 | # limit_commits = 42 88 | -------------------------------------------------------------------------------- /lib/flutterpi_tool.dart: -------------------------------------------------------------------------------- 1 | export 'src/executable.dart' show main; 2 | -------------------------------------------------------------------------------- /lib/src/application_package_factory.dart: -------------------------------------------------------------------------------- 1 | import 'package:file/file.dart'; 2 | import 'package:flutterpi_tool/src/devices/flutterpi_ssh/device.dart'; 3 | import 'package:flutterpi_tool/src/fltool/common.dart'; 4 | 5 | class FlutterpiApplicationPackageFactory 6 | implements FlutterApplicationPackageFactory { 7 | @override 8 | Future getPackageForPlatform( 9 | TargetPlatform platform, { 10 | BuildInfo? buildInfo, 11 | File? applicationBinary, 12 | }) async { 13 | switch (platform) { 14 | case TargetPlatform.linux_arm64: 15 | case TargetPlatform.linux_x64: 16 | final flutterProject = FlutterProject.current(); 17 | 18 | return BuildableFlutterpiAppBundle( 19 | id: flutterProject.manifest.appName, 20 | name: flutterProject.manifest.appName, 21 | displayName: flutterProject.manifest.appName, 22 | ); 23 | default: 24 | return null; 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /lib/src/archive.dart: -------------------------------------------------------------------------------- 1 | // The archive package that the flutter SDK uses (3.3.2 as of 29-05-2024) 2 | // has a bug where it can't properly extract xz archives (I suspect when they 3 | // where created with multithreaded compression) 4 | // So we frankenstein together a newer XZDecoder from archive 3.6.0 with the 5 | // archive 3.3.2 that flutter uses. 6 | export 'package:archive/archive_io.dart' hide XZDecoder; 7 | export 'archive/xz_decoder.dart'; 8 | -------------------------------------------------------------------------------- /lib/src/archive/lzma/lzma_decoder.dart: -------------------------------------------------------------------------------- 1 | /// Copyright (c) 2013-2021 Brendan Duncan 2 | /// MIT License 3 | 4 | import 'dart:typed_data'; 5 | 6 | import 'package:archive/archive_io.dart'; 7 | 8 | import 'range_decoder.dart'; 9 | 10 | // LZMA is not well specified, but useful sources to understanding it can be found at: 11 | // https://github.com/jljusten/LZMA-SDK/blob/master/DOC/lzma-specification.txt 12 | // https://en.wikipedia.org/wiki/Lempel%E2%80%93Ziv%E2%80%93Markov_chain_algorithm 13 | // https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/lib/xz 14 | 15 | class LzmaDecoder { 16 | // Compressed data. 17 | final _rc = RangeDecoder(); 18 | 19 | // Number of bits used from [_dictionary] length for probabilities. 20 | int _positionBits = 2; 21 | 22 | // Number of bits used from [_dictionary] length for literal probabilities. 23 | int _literalPositionBits = 0; 24 | 25 | // Number of bits used from [_dictionary] for literal probabilities. 26 | int _literalContextBits = 3; 27 | 28 | // Bit probabilities for determining which LZMA packet is present. 29 | final _nonLiteralTables = []; 30 | late final RangeDecoderTable _repeatTable; 31 | late final RangeDecoderTable _repeat0Table; 32 | final _longRepeat0Tables = []; 33 | late final RangeDecoderTable _repeat1Table; 34 | late final RangeDecoderTable _repeat2Table; 35 | 36 | // Bit probabilities when decoding literals. 37 | final _literalTables = []; 38 | final _matchLiteralTables0 = []; 39 | final _matchLiteralTables1 = []; 40 | 41 | // Decoder to read length fields in match packets. 42 | late final _LengthDecoder _matchLengthDecoder; 43 | 44 | // Decoder to read length fields in repeat packets. 45 | late final _LengthDecoder _repeatLengthDecoder; 46 | 47 | // Decoder to read distance fields in match packaets. 48 | late final _DistanceDecoder _distanceDecoder; 49 | 50 | // Distances used in matches that can be repeated. 51 | var _distance0 = 0; 52 | var _distance1 = 0; 53 | var _distance2 = 0; 54 | var _distance3 = 0; 55 | 56 | // Decoder state, used in range decoding. 57 | var state = _LzmaState.litLit; 58 | 59 | // Decoded data, which is able to be copied. 60 | var _dictionary = Uint8List(0); 61 | var _writePosition = 0; 62 | 63 | /// Creates an LZMA decoder. 64 | LzmaDecoder() { 65 | for (var i = 0; i < _LzmaState.values.length; i++) { 66 | _nonLiteralTables.add(RangeDecoderTable(_LzmaState.values.length)); 67 | } 68 | _repeatTable = RangeDecoderTable(_LzmaState.values.length); 69 | _repeat0Table = RangeDecoderTable(_LzmaState.values.length); 70 | for (var i = 0; i < _LzmaState.values.length; i++) { 71 | _longRepeat0Tables.add(RangeDecoderTable(_LzmaState.values.length)); 72 | } 73 | _repeat1Table = RangeDecoderTable(_LzmaState.values.length); 74 | _repeat2Table = RangeDecoderTable(_LzmaState.values.length); 75 | 76 | var positionCount = 1 << _positionBits; 77 | _matchLengthDecoder = _LengthDecoder(_rc, positionCount); 78 | _repeatLengthDecoder = _LengthDecoder(_rc, positionCount); 79 | _distanceDecoder = _DistanceDecoder(_rc); 80 | 81 | reset(); 82 | } 83 | 84 | // Reset the decoder. 85 | void reset( 86 | {int? positionBits, 87 | int? literalPositionBits, 88 | int? literalContextBits, 89 | bool resetDictionary = false}) { 90 | _positionBits = positionBits ?? _positionBits; 91 | _literalPositionBits = literalPositionBits ?? _literalPositionBits; 92 | _literalContextBits = literalContextBits ?? _literalContextBits; 93 | 94 | state = _LzmaState.litLit; 95 | _distance0 = 0; 96 | _distance1 = 0; 97 | _distance2 = 0; 98 | _distance3 = 0; 99 | 100 | final maxLiteralStates = 1 << (_literalPositionBits + _literalContextBits); 101 | if (_literalTables.length != maxLiteralStates) { 102 | for (var i = _literalTables.length; i < maxLiteralStates; i++) { 103 | _literalTables.add(RangeDecoderTable(256)); 104 | _matchLiteralTables0.add(RangeDecoderTable(256)); 105 | _matchLiteralTables1.add(RangeDecoderTable(256)); 106 | } 107 | } 108 | 109 | for (final table in _nonLiteralTables) { 110 | table.reset(); 111 | } 112 | _repeatTable.reset(); 113 | _repeat0Table.reset(); 114 | for (final table in _longRepeat0Tables) { 115 | table.reset(); 116 | } 117 | _repeat1Table.reset(); 118 | _repeat2Table.reset(); 119 | for (final table in _literalTables) { 120 | table.reset(); 121 | } 122 | for (final table in _matchLiteralTables0) { 123 | table.reset(); 124 | } 125 | for (final table in _matchLiteralTables1) { 126 | table.reset(); 127 | } 128 | 129 | final positionCount = 1 << _positionBits; 130 | _matchLengthDecoder.reset(positionCount); 131 | _repeatLengthDecoder.reset(positionCount); 132 | _distanceDecoder.reset(); 133 | 134 | if (resetDictionary) { 135 | _dictionary = Uint8List(0); 136 | _writePosition = 0; 137 | } 138 | } 139 | 140 | Uint8List decodeUncompressed(InputStreamBase input, int uncompressedLength) { 141 | final inputData = input.readBytes(uncompressedLength); 142 | 143 | var initialSize = _dictionary.length; 144 | var finalSize = initialSize + uncompressedLength; 145 | final newDictionary = Uint8List(finalSize); 146 | newDictionary.setAll(0, _dictionary); 147 | _dictionary = newDictionary; 148 | 149 | final inputBytes = inputData.toUint8List(); 150 | _dictionary.setAll(initialSize, inputBytes); 151 | _writePosition += uncompressedLength; 152 | 153 | return inputBytes; 154 | } 155 | 156 | // Decode [input] which contains compressed LZMA data that unpacks to 157 | // [uncompressedLength] bytes. 158 | Uint8List decode(InputStreamBase input, int uncompressedLength) { 159 | _rc.input = input; 160 | _rc.initialize(); 161 | 162 | // Expand dictionary to fit new data. 163 | var initialSize = _dictionary.length; 164 | var finalSize = initialSize + uncompressedLength; 165 | final newDictionary = Uint8List(finalSize); 166 | newDictionary.setAll(0, _dictionary); 167 | _dictionary = newDictionary; 168 | 169 | // Decode packets (literal, match or repeat) until all the data has been 170 | // decoded. 171 | while (_writePosition < finalSize) { 172 | final positionMask = (1 << _positionBits) - 1; 173 | final posState = _writePosition & positionMask; 174 | if (_rc.readBit(_nonLiteralTables[state.index], posState) == 0) { 175 | _decodeLiteral(); 176 | } else if (_rc.readBit(_repeatTable, state.index) == 0) { 177 | _decodeMatch(posState); 178 | } else { 179 | _decodeRepeat(posState); 180 | } 181 | } 182 | 183 | // Return new data added to the dictionary. 184 | return _dictionary.sublist(initialSize); 185 | } 186 | 187 | // Returns true if the previous packet seen was a literal. 188 | bool _prevPacketIsLiteral() { 189 | switch (state) { 190 | case _LzmaState.litLit: 191 | case _LzmaState.matchLitLit: 192 | case _LzmaState.repLitLit: 193 | case _LzmaState.shortRepLitLit: 194 | case _LzmaState.matchLit: 195 | case _LzmaState.repLit: 196 | case _LzmaState.shortRepLit: 197 | return true; 198 | case _LzmaState.litMatch: 199 | case _LzmaState.litLongRep: 200 | case _LzmaState.litShortRep: 201 | case _LzmaState.nonLitMatch: 202 | case _LzmaState.nonLitRep: 203 | return false; 204 | } 205 | } 206 | 207 | // Decode a packet containing a literal byte. 208 | void _decodeLiteral() { 209 | // Get probabilities based on previous byte written. 210 | var prevByte = _writePosition > 0 ? _dictionary[_writePosition - 1] : 0; 211 | final low = prevByte >> (8 - _literalContextBits); 212 | final positionMask = (1 << _literalPositionBits) - 1; 213 | final high = (_writePosition & positionMask) << _literalContextBits; 214 | final hash = low + high; 215 | final table = _literalTables[hash]; 216 | 217 | int value; 218 | if (_prevPacketIsLiteral()) { 219 | value = _rc.readBittree(table, 8); 220 | } else { 221 | // Get the last byte before the match that just occurred. 222 | prevByte = _dictionary[_writePosition - _distance0 - 1]; 223 | 224 | value = 0; 225 | var symbolPrefix = 1; 226 | var matched = true; 227 | final matchTable0 = _matchLiteralTables0[hash]; 228 | final matchTable1 = _matchLiteralTables1[hash]; 229 | for (var i = 0; i < 8; i++) { 230 | int b; 231 | if (matched) { 232 | final matchBit = (prevByte >> 7) & 0x1; 233 | prevByte <<= 1; 234 | b = _rc.readBit( 235 | matchBit == 0 ? matchTable0 : matchTable1, symbolPrefix | value); 236 | matched = b == matchBit; 237 | } else { 238 | b = _rc.readBit(table, symbolPrefix | value); 239 | } 240 | value = (value << 1) | b; 241 | symbolPrefix <<= 1; 242 | } 243 | } 244 | 245 | // Add new byte to the output. 246 | _dictionary[_writePosition] = value; 247 | _writePosition++; 248 | 249 | // Update state. 250 | switch (state) { 251 | case _LzmaState.litLit: 252 | case _LzmaState.matchLitLit: 253 | case _LzmaState.repLitLit: 254 | case _LzmaState.shortRepLitLit: 255 | state = _LzmaState.litLit; 256 | break; 257 | case _LzmaState.matchLit: 258 | state = _LzmaState.matchLitLit; 259 | break; 260 | case _LzmaState.repLit: 261 | state = _LzmaState.repLitLit; 262 | break; 263 | case _LzmaState.shortRepLit: 264 | state = _LzmaState.shortRepLitLit; 265 | break; 266 | case _LzmaState.litMatch: 267 | case _LzmaState.nonLitMatch: 268 | state = _LzmaState.matchLit; 269 | break; 270 | case _LzmaState.litLongRep: 271 | case _LzmaState.nonLitRep: 272 | state = _LzmaState.repLit; 273 | break; 274 | case _LzmaState.litShortRep: 275 | state = _LzmaState.shortRepLit; 276 | break; 277 | } 278 | } 279 | 280 | // Decode a packet that matches some already decoded data. 281 | void _decodeMatch(int posState) { 282 | final length = _matchLengthDecoder.readLength(posState); 283 | final distance = _distanceDecoder.readDistance(length); 284 | 285 | _repeatData(distance, length); 286 | 287 | _distance3 = _distance2; 288 | _distance2 = _distance1; 289 | _distance1 = _distance0; 290 | _distance0 = distance; 291 | 292 | state = 293 | _prevPacketIsLiteral() ? _LzmaState.litMatch : _LzmaState.nonLitMatch; 294 | } 295 | 296 | // Decode a packet that repeats a match already done. 297 | void _decodeRepeat(int posState) { 298 | int distance; 299 | if (_rc.readBit(_repeat0Table, state.index) == 0) { 300 | if (_rc.readBit(_longRepeat0Tables[state.index], posState) == 0) { 301 | _repeatData(_distance0, 1); 302 | state = _prevPacketIsLiteral() 303 | ? _LzmaState.litShortRep 304 | : _LzmaState.nonLitRep; 305 | return; 306 | } else { 307 | distance = _distance0; 308 | } 309 | } else if (_rc.readBit(_repeat1Table, state.index) == 0) { 310 | distance = _distance1; 311 | _distance1 = _distance0; 312 | _distance0 = distance; 313 | } else if (_rc.readBit(_repeat2Table, state.index) == 0) { 314 | distance = _distance2; 315 | _distance2 = _distance1; 316 | _distance1 = _distance0; 317 | _distance0 = distance; 318 | } else { 319 | distance = _distance3; 320 | _distance3 = _distance2; 321 | _distance2 = _distance1; 322 | _distance1 = _distance0; 323 | _distance0 = distance; 324 | } 325 | 326 | var length = _repeatLengthDecoder.readLength(posState); 327 | _repeatData(distance, length); 328 | 329 | // Update state. 330 | state = 331 | _prevPacketIsLiteral() ? _LzmaState.litLongRep : _LzmaState.nonLitRep; 332 | } 333 | 334 | // Repeat decompressed data, starting [distance] bytes back from the end of 335 | // the buffer and copying [length] bytes. 336 | void _repeatData(int distance, int length) { 337 | final start = _writePosition - distance - 1; 338 | for (var i = 0; i < length; i++) { 339 | if (start < 0 || _writePosition < 0) { 340 | break; 341 | } 342 | _dictionary[_writePosition] = _dictionary[start + i]; 343 | _writePosition++; 344 | /*if (_writePosition >= _dictionary.length) { 345 | break; // TODO: what is this? 346 | }*/ 347 | } 348 | } 349 | } 350 | 351 | // The decoder state which tracks the sequence of LZMA packets received. 352 | enum _LzmaState { 353 | litLit, 354 | matchLitLit, 355 | repLitLit, 356 | shortRepLitLit, 357 | matchLit, 358 | repLit, 359 | shortRepLit, 360 | litMatch, 361 | litLongRep, 362 | litShortRep, 363 | nonLitMatch, 364 | nonLitRep 365 | } 366 | 367 | // Decodes match/repeat length fields from LZMA data. 368 | class _LengthDecoder { 369 | // Data being read from. 370 | final RangeDecoder _input; 371 | 372 | // Bit probabilities for the length form. 373 | late final RangeDecoderTable formTable; 374 | 375 | // Bit probabilities when lengths are in the short form (2-9). 376 | late final List shortTables; 377 | 378 | // Bit probabilities when lengths are in the medium form (10-17). 379 | late final List mediumTables; 380 | 381 | // Bit probabilities when lengths are in the long form (18-273). 382 | late final RangeDecoderTable longTable; 383 | 384 | _LengthDecoder(this._input, int positionCount) { 385 | formTable = RangeDecoderTable(2); 386 | shortTables = []; 387 | mediumTables = []; 388 | longTable = RangeDecoderTable(256); 389 | 390 | reset(positionCount); 391 | } 392 | 393 | // Reset this decoder. 394 | void reset(int positionCount) { 395 | formTable.reset(); 396 | if (positionCount != shortTables.length) { 397 | shortTables.clear(); 398 | mediumTables.clear(); 399 | for (var i = 0; i < positionCount; i++) { 400 | shortTables.add(RangeDecoderTable(8)); 401 | mediumTables.add(RangeDecoderTable(8)); 402 | } 403 | } else { 404 | for (var table in shortTables) { 405 | table.reset(); 406 | } 407 | for (var table in mediumTables) { 408 | table.reset(); 409 | } 410 | } 411 | longTable.reset(); 412 | } 413 | 414 | // Read a length field. 415 | int readLength(int posState) { 416 | if (_input.readBit(formTable, 0) == 0) { 417 | // 0xxx - Length 2 - 9 418 | return 2 + _input.readBittree(shortTables[posState], 3); 419 | } else if (_input.readBit(formTable, 1) == 0) { 420 | // 10xxx - Length 10 - 17 421 | return 10 + _input.readBittree(mediumTables[posState], 3); 422 | } else { 423 | // 11xxxxxxxx - Length 18 - 273 424 | return 18 + _input.readBittree(longTable, 8); 425 | } 426 | } 427 | } 428 | 429 | // Decodes match distance fields from LZMA data. 430 | class _DistanceDecoder { 431 | // Number of bits in a slot. 432 | final int _slotBitCount = 6; 433 | 434 | // Number of aligned bits. 435 | final int _alignBitCount = 4; 436 | 437 | // Data being read from. 438 | final RangeDecoder _input; 439 | 440 | // Bit probabilities for the 6 bit slot. 441 | late final List _slotTables; 442 | 443 | // Bit probabilities for slots 4-13. 444 | late final List _shortTables; 445 | 446 | // Bit probabilities for slots 14-63. 447 | late final RangeDecoderTable _longTable; 448 | 449 | _DistanceDecoder(this._input) { 450 | _slotTables = []; 451 | var slotSize = 1 << _slotBitCount; 452 | for (var i = 0; i < 4; i++) { 453 | _slotTables.add(RangeDecoderTable(slotSize)); 454 | } 455 | _shortTables = []; 456 | for (var slot = 4; slot < 14; slot++) { 457 | var bitCount = (slot ~/ 2) - 1; 458 | _shortTables.add(RangeDecoderTable(1 << bitCount)); 459 | } 460 | var alignSize = 1 << _alignBitCount; 461 | _longTable = RangeDecoderTable(alignSize); 462 | } 463 | 464 | // Reset this decoder. 465 | void reset() { 466 | for (var table in _slotTables) { 467 | table.reset(); 468 | } 469 | for (var table in _shortTables) { 470 | table.reset(); 471 | } 472 | _longTable.reset(); 473 | } 474 | 475 | // Reads a distance field. 476 | // [length] is a match length (minimum of 2). 477 | int readDistance(int length) { 478 | var distState = length - 2; 479 | if (distState >= _slotTables.length) { 480 | distState = _slotTables.length - 1; 481 | } 482 | final table = _slotTables[distState]; 483 | 484 | // Distances are encoded starting with a six bit slot. 485 | final slot = _input.readBittree(table, _slotBitCount); 486 | 487 | // Slots 0-3 map to the distances 0-3. 488 | if (slot < 4) { 489 | return slot; 490 | } 491 | 492 | // Larger slots have a variable number of bits that follow. 493 | final prefix = 0x2 | (slot & 0x1); 494 | final bitCount = (slot ~/ 2) - 1; 495 | 496 | // Short distances are stored in reverse bittree format. 497 | if (slot < 14) { 498 | final result = (prefix << bitCount) | 499 | _input.readBittreeReverse(_shortTables[slot - 4], bitCount); 500 | return result; 501 | } 502 | 503 | // Large distances are a combination of direct bits and reverse bittree format. 504 | final directCount = bitCount - _alignBitCount; 505 | final directBits = _input.readDirect(directCount); 506 | final alignBits = _input.readBittreeReverse(_longTable, _alignBitCount); 507 | final r1 = (prefix << bitCount) & 0xffffffff; 508 | final r2 = (directBits << _alignBitCount) & 0xffffffff; 509 | final result = r1 | r2 | alignBits; 510 | return result; 511 | } 512 | } 513 | -------------------------------------------------------------------------------- /lib/src/archive/lzma/range_decoder.dart: -------------------------------------------------------------------------------- 1 | /// Copyright (c) 2013-2021 Brendan Duncan 2 | /// MIT License 3 | 4 | import 'dart:typed_data'; 5 | 6 | import 'package:archive/archive_io.dart'; 7 | 8 | // Number of bits used for probabilities. 9 | const _probabilityBitCount = 11; 10 | 11 | // Value used for a probability of 1.0. 12 | const _probabilityOne = (1 << _probabilityBitCount); 13 | 14 | // Value used for a probability of 0.5. 15 | const _probabilityHalf = _probabilityOne ~/ 2; 16 | 17 | // Probability table used with [RangeDecoder]. 18 | class RangeDecoderTable { 19 | // Table of probabilities for each symbol. 20 | final Uint16List table; 21 | 22 | // Creates a new probability table for [length] elements. 23 | RangeDecoderTable(int length) : table = Uint16List(length) { 24 | reset(); 25 | } 26 | 27 | // Reset the table to probabilities of 0.5. 28 | void reset() { 29 | table.fillRange(0, table.length, _probabilityHalf); 30 | } 31 | } 32 | 33 | // Implements the LZMA range decoder. 34 | class RangeDecoder { 35 | // Data being read from. 36 | late InputStreamBase _input; 37 | 38 | // Mask showing the current bits in [code]. 39 | var range = 0xffffffff; 40 | 41 | // Current code being stored. 42 | var code = 0; 43 | 44 | // Set the input being read from. Must be set before initializing or reading 45 | // bits. 46 | set input(InputStreamBase value) { 47 | _input = value; 48 | } 49 | 50 | void reset() { 51 | range = 0xffffffff; 52 | code = 0; 53 | } 54 | 55 | void initialize() { 56 | code = 0; 57 | range = 0xffffffff; 58 | // Skip the first byte, then load four for the initial state. 59 | _input.skip(1); 60 | for (var i = 0; i < 4; i++) { 61 | code = (code << 8 | _input.readByte()); 62 | } 63 | } 64 | 65 | // Read a single bit from the decoder, using the supplied [index] into a 66 | // probabilities [table]. 67 | int readBit(RangeDecoderTable table, int index) { 68 | _load(); 69 | 70 | final p = table.table[index]; 71 | final bound = (range >> _probabilityBitCount) * p; 72 | const moveBits = 5; 73 | if (code < bound) { 74 | range = bound; 75 | final oneMinusP = _probabilityOne - p; 76 | final shifted = oneMinusP >> moveBits; 77 | table.table[index] += shifted; 78 | return 0; 79 | } else { 80 | range -= bound; 81 | code -= bound; 82 | table.table[index] -= p >> moveBits; 83 | return 1; 84 | } 85 | } 86 | 87 | // Read a bittree (big endian) of [count] bits from the decoder. 88 | int readBittree(RangeDecoderTable table, int count) { 89 | var value = 0; 90 | var symbolPrefix = 1; 91 | for (var i = 0; i < count; i++) { 92 | final b = readBit(table, symbolPrefix | value); 93 | value = ((value << 1) | b) & 0xffffffff; 94 | symbolPrefix = (symbolPrefix << 1) & 0xffffffff; 95 | } 96 | 97 | return value; 98 | } 99 | 100 | // Read a reverse bittree (little endian) of [count] bits from the decoder. 101 | int readBittreeReverse(RangeDecoderTable table, int count) { 102 | var value = 0; 103 | var symbolPrefix = 1; 104 | for (var i = 0; i < count; i++) { 105 | final b = readBit(table, symbolPrefix | value); 106 | value = (value | b << i) & 0xffffffff; 107 | symbolPrefix = (symbolPrefix << 1) & 0xffffffff; 108 | } 109 | 110 | return value; 111 | } 112 | 113 | // Read [count] bits directly from the decoder. 114 | int readDirect(int count) { 115 | var value = 0; 116 | for (var i = 0; i < count; i++) { 117 | _load(); 118 | range >>= 1; 119 | code -= range; 120 | value <<= 1; 121 | if (code & 0x80000000 != 0) { 122 | code += range; 123 | } else { 124 | value++; 125 | } 126 | } 127 | 128 | return value; 129 | } 130 | 131 | // Load a byte if we can fit it. 132 | void _load() { 133 | const topValue = 1 << 24; 134 | if (range < topValue) { 135 | range <<= 8; 136 | code = (code << 8) | _input.readByte(); 137 | } 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /lib/src/archive/xz_decoder.dart: -------------------------------------------------------------------------------- 1 | /// Copyright (c) 2013-2021 Brendan Duncan 2 | /// MIT License 3 | 4 | import 'dart:typed_data'; 5 | 6 | import 'package:archive/archive_io.dart'; 7 | import 'package:crypto/crypto.dart'; 8 | import 'package:flutterpi_tool/src/archive/lzma/lzma_decoder.dart'; 9 | 10 | // The XZ specification can be found at 11 | // https://tukaani.org/xz/xz-file-format.txt. 12 | 13 | /// Decompress data with the xz format decoder. 14 | class XZDecoder { 15 | List decodeBytes(List data, {bool verify = false}) { 16 | return decodeBuffer(InputStream(data), verify: verify); 17 | } 18 | 19 | List decodeBuffer(InputStreamBase input, {bool verify = false}) { 20 | var decoder = _XZStreamDecoder(verify: verify); 21 | return decoder.decode(input); 22 | } 23 | } 24 | 25 | /// Decodes an XZ stream. 26 | class _XZStreamDecoder { 27 | // True if checksums are confirmed. 28 | final bool verify; 29 | 30 | // Decoded data. 31 | final data = BytesBuilder(); 32 | 33 | // LZMA decoder. 34 | final decoder = LzmaDecoder(); 35 | 36 | // Stream flags, which are sent in both the header and the footer. 37 | var streamFlags = 0; 38 | 39 | // Block sizes. 40 | final _blockSizes = <_XZBlockSize>[]; 41 | 42 | _XZStreamDecoder({this.verify = false}); 43 | 44 | // Decode this stream and return the uncompressed data. 45 | List decode(InputStreamBase input) { 46 | _readStreamHeader(input); 47 | 48 | while (true) { 49 | var blockHeader = input.peekBytes(1).readByte(); 50 | 51 | if (blockHeader == 0) { 52 | var indexSize = _readStreamIndex(input); 53 | _readStreamFooter(input, indexSize); 54 | return data.takeBytes(); 55 | } 56 | 57 | var blockLength = (blockHeader + 1) * 4; 58 | _readBlock(input, blockLength); 59 | } 60 | } 61 | 62 | // Reads an XZ steam header from [input]. 63 | void _readStreamHeader(InputStreamBase input) { 64 | final magic = input.readBytes(6).toUint8List(); 65 | final magicIsValid = magic[0] == 253 && 66 | magic[1] == 55 /* '7' */ && 67 | magic[2] == 122 /* 'z' */ && 68 | magic[3] == 88 /* 'X' */ && 69 | magic[4] == 90 /* 'Z' */ && 70 | magic[5] == 0; 71 | if (!magicIsValid) { 72 | throw ArchiveException('Invalid XZ stream header signature'); 73 | } 74 | 75 | final header = input.readBytes(2); 76 | if (header.readByte() != 0) { 77 | throw ArchiveException('Invalid stream flags'); 78 | } 79 | streamFlags = header.readByte(); 80 | header.reset(); 81 | 82 | final crc = input.readUint32(); 83 | if (getCrc32(header.toUint8List()) != crc) { 84 | throw ArchiveException('Invalid stream header CRC checksum'); 85 | } 86 | } 87 | 88 | // Reads a data block from [input]. 89 | void _readBlock(InputStreamBase input, int headerLength) { 90 | final blockStart = input.position; 91 | final header = input.readBytes(headerLength - 4); 92 | 93 | header.skip(1); // Skip length field 94 | final blockFlags = header.readByte(); 95 | final nFilters = (blockFlags & 0x3) + 1; 96 | final hasCompressedLength = blockFlags & 0x40 != 0; 97 | final hasUncompressedLength = blockFlags & 0x80 != 0; 98 | 99 | int? compressedLength; 100 | if (hasCompressedLength) { 101 | compressedLength = _readMultibyteInteger(header); 102 | } 103 | int? uncompressedLength; 104 | if (hasUncompressedLength) { 105 | uncompressedLength = _readMultibyteInteger(header); 106 | } 107 | 108 | final filters = []; 109 | var dictionarySize = 0; 110 | for (var i = 0; i < nFilters; i++) { 111 | final id = _readMultibyteInteger(header); 112 | final propertiesLength = _readMultibyteInteger(header); 113 | final properties = header.readBytes(propertiesLength).toUint8List(); 114 | if (id == 0x03) { 115 | // delta filter 116 | final distance = properties[0]; 117 | filters.add(id); 118 | filters.add(distance); 119 | } else if (id == 0x21) { 120 | // lzma2 filter 121 | final v = properties[0]; 122 | if (v > 40) { 123 | throw ArchiveException('Invalid LZMA dictionary size'); 124 | } else if (v == 40) { 125 | dictionarySize = 0xffffffff; 126 | } else { 127 | final mantissa = 2 | (v & 0x1); 128 | final exponent = (v >> 1) + 11; 129 | dictionarySize = mantissa << exponent; 130 | } 131 | filters.add(id); 132 | filters.add(dictionarySize); 133 | } else { 134 | filters.add(id); 135 | filters.add(0); 136 | } 137 | } 138 | _readPadding(header); 139 | header.reset(); 140 | 141 | final crc = input.readUint32(); 142 | if (getCrc32(header.toUint8List()) != crc) { 143 | throw ArchiveException('Invalid block CRC checksum'); 144 | } 145 | 146 | if (filters.length != 2 && filters.first != 0x21) { 147 | throw ArchiveException('Unsupported filters'); 148 | } 149 | 150 | final startPosition = input.position; 151 | final startDataLength = data.length; 152 | 153 | _readLZMA2(input, dictionarySize); 154 | 155 | final actualCompressedLength = input.position - startPosition; 156 | final actualUncompressedLength = data.length - startDataLength; 157 | 158 | if (compressedLength != null && 159 | compressedLength != actualCompressedLength) { 160 | throw ArchiveException("Compressed data doesn't match expected length"); 161 | } 162 | 163 | uncompressedLength ??= actualUncompressedLength; 164 | if (uncompressedLength != actualUncompressedLength) { 165 | throw ArchiveException("Uncompressed data doesn't match expected length"); 166 | } 167 | 168 | final paddingSize = _readPadding(input); 169 | 170 | // Checksum 171 | final checkType = streamFlags & 0xf; 172 | switch (checkType) { 173 | case 0: // none 174 | break; 175 | case 0x1: // CRC32 176 | final expectedCrc = input.readUint32(); 177 | if (verify) { 178 | final actualCrc = getCrc32(data.toBytes().sublist(startDataLength)); 179 | if (actualCrc != expectedCrc) { 180 | throw ArchiveException('CRC32 check failed'); 181 | } 182 | } 183 | break; 184 | case 0x2: 185 | case 0x3: 186 | input.skip(4); 187 | if (verify) { 188 | throw ArchiveException('Unknown check type $checkType'); 189 | } 190 | break; 191 | case 0x4: // CRC64 192 | final expectedCrc = input.readUint64(); 193 | if (verify && isCrc64Supported()) { 194 | final actualCrc = getCrc64(data.toBytes().sublist(startDataLength)); 195 | if (actualCrc != expectedCrc) { 196 | throw ArchiveException('CRC64 check failed'); 197 | } 198 | } 199 | break; 200 | case 0x5: 201 | case 0x6: 202 | input.skip(8); 203 | if (verify) { 204 | throw ArchiveException('Unknown check type $checkType'); 205 | } 206 | break; 207 | case 0x7: 208 | case 0x8: 209 | case 0x9: 210 | input.skip(16); 211 | if (verify) { 212 | throw ArchiveException('Unknown check type $checkType'); 213 | } 214 | break; 215 | case 0xa: // SHA-256 216 | final expectedCrc = input.readBytes(32).toUint8List(); 217 | if (verify) { 218 | final actualCrc = 219 | sha256.convert(data.toBytes().sublist(startDataLength)).bytes; 220 | for (var i = 0; i < 32; i++) { 221 | if (actualCrc[i] != expectedCrc[i]) { 222 | throw ArchiveException('SHA-256 check failed'); 223 | } 224 | } 225 | } 226 | break; 227 | case 0xb: 228 | case 0xc: 229 | input.skip(32); 230 | if (verify) { 231 | throw ArchiveException('Unknown check type $checkType'); 232 | } 233 | break; 234 | case 0xd: 235 | case 0xe: 236 | case 0xf: 237 | input.skip(64); 238 | if (verify) { 239 | throw ArchiveException('Unknown check type $checkType'); 240 | } 241 | break; 242 | default: 243 | throw ArchiveException('Unknown block check type $checkType'); 244 | } 245 | 246 | final unpaddedLength = input.position - blockStart - paddingSize; 247 | _blockSizes.add(_XZBlockSize(unpaddedLength, uncompressedLength)); 248 | } 249 | 250 | // Reads LZMA2 data from [input]. 251 | void _readLZMA2(InputStreamBase input, int dictionarySize) { 252 | while (true) { 253 | final control = input.readByte(); 254 | // Control values: 255 | // 00000000 - end marker 256 | // 00000001 - reset dictionary and uncompresed data 257 | // 00000010 - uncompressed data 258 | // 1rrxxxxx - LZMA data with reset (r) and high bits of size field (x) 259 | if (control & 0x80 == 0) { 260 | if (control == 0) { 261 | decoder.reset(resetDictionary: true); 262 | return; 263 | } else if (control == 1) { 264 | final length = (input.readByte() << 8 | input.readByte()) + 1; 265 | data.add(input.readBytes(length).toUint8List()); 266 | } else if (control == 2) { 267 | // uncompressed data 268 | final length = (input.readByte() << 8 | input.readByte()) + 1; 269 | data.add(decoder.decodeUncompressed(input.readBytes(length), length)); 270 | } else { 271 | throw ArchiveException('Unknown LZMA2 control code $control'); 272 | } 273 | } else { 274 | // Reset flags: 275 | // 0 - reset nothing 276 | // 1 - reset state 277 | // 2 - reset state, properties 278 | // 3 - reset state, properties and dictionary 279 | final reset = (control >> 5) & 0x3; 280 | final uncompressedLength = ((control & 0x1f) << 16 | 281 | input.readByte() << 8 | 282 | input.readByte()) + 283 | 1; 284 | final compressedLength = (input.readByte() << 8 | input.readByte()) + 1; 285 | int? literalContextBits; 286 | int? literalPositionBits; 287 | int? positionBits; 288 | if (reset >= 2) { 289 | // The three LZMA decoder properties are combined into a single number. 290 | var properties = input.readByte(); 291 | positionBits = properties ~/ 45; 292 | properties -= positionBits * 45; 293 | literalPositionBits = properties ~/ 9; 294 | literalContextBits = properties - literalPositionBits * 9; 295 | } 296 | if (reset > 0) { 297 | decoder.reset( 298 | literalContextBits: literalContextBits, 299 | literalPositionBits: literalPositionBits, 300 | positionBits: positionBits, 301 | resetDictionary: reset == 3); 302 | } 303 | 304 | data.add(decoder.decode( 305 | input.readBytes(compressedLength), uncompressedLength)); 306 | } 307 | } 308 | } 309 | 310 | // Reads an XZ stream index from [input]. 311 | // Returns the length of the index in bytes. 312 | int _readStreamIndex(InputStreamBase input) { 313 | final startPosition = input.position; 314 | input.skip(1); // Skip index indicator 315 | final nRecords = _readMultibyteInteger(input); 316 | if (nRecords != _blockSizes.length) { 317 | throw ArchiveException('Stream index block count mismatch'); 318 | } 319 | 320 | for (var i = 0; i < nRecords; i++) { 321 | final unpaddedLength = _readMultibyteInteger(input); 322 | final uncompressedLength = _readMultibyteInteger(input); 323 | if (_blockSizes[i].unpaddedLength != unpaddedLength) { 324 | throw ArchiveException('Stream index compressed length mismatch'); 325 | } 326 | if (_blockSizes[i].uncompressedLength != uncompressedLength) { 327 | throw ArchiveException('Stream index uncompressed length mismatch'); 328 | } 329 | } 330 | _readPadding(input); 331 | 332 | // Re-read for CRC calculation 333 | final indexLength = input.position - startPosition; 334 | input.rewind(indexLength); 335 | final indexData = input.readBytes(indexLength); 336 | 337 | final crc = input.readUint32(); 338 | if (getCrc32(indexData.toUint8List()) != crc) { 339 | throw ArchiveException('Invalid stream index CRC checksum'); 340 | } 341 | 342 | return indexLength + 4; 343 | } 344 | 345 | // Reads an XZ stream footer from [input] and check the index size matches 346 | // [indexSize]. 347 | void _readStreamFooter(InputStreamBase input, int indexSize) { 348 | final crc = input.readUint32(); 349 | final footer = input.readBytes(6); 350 | final backwardSize = (footer.readUint32() + 1) * 4; 351 | if (backwardSize != indexSize) { 352 | throw ArchiveException('Stream footer has invalid index size'); 353 | } 354 | if (footer.readByte() != 0) { 355 | throw ArchiveException('Invalid stream flags'); 356 | } 357 | final footerFlags = footer.readByte(); 358 | if (footerFlags != streamFlags) { 359 | throw ArchiveException("Stream footer flags don't match header flags"); 360 | } 361 | footer.reset(); 362 | 363 | if (getCrc32(footer.toUint8List()) != crc) { 364 | throw ArchiveException('Invalid stream footer CRC checksum'); 365 | } 366 | 367 | final magic = input.readBytes(2).toUint8List(); 368 | if (magic[0] != 89 /* 'Y' */ && magic[1] != 90 /* 'Z' */) { 369 | throw ArchiveException('Invalid XZ stream footer signature'); 370 | } 371 | } 372 | 373 | // Reads a multibyte integer from [input]. 374 | int _readMultibyteInteger(InputStreamBase input) { 375 | var value = 0; 376 | var shift = 0; 377 | while (true) { 378 | final data = input.readByte(); 379 | value |= (data & 0x7f) << shift; 380 | if (data & 0x80 == 0) { 381 | return value; 382 | } 383 | shift += 7; 384 | } 385 | } 386 | 387 | // Reads padding from [input] until the read position is aligned to a 4 byte 388 | // boundary. The padding bytes are confirmed to be zeros. 389 | // Returns he number of padding bytes. 390 | int _readPadding(InputStreamBase input) { 391 | var count = 0; 392 | while (input.position % 4 != 0) { 393 | if (input.readByte() != 0) { 394 | throw ArchiveException('Non-zero padding byte'); 395 | } 396 | count++; 397 | } 398 | return count; 399 | } 400 | } 401 | 402 | // Information about a block size. 403 | class _XZBlockSize { 404 | // The block size excluding padding. 405 | final int unpaddedLength; 406 | 407 | // The size of the data in the block when uncompressed. 408 | final int uncompressedLength; 409 | 410 | const _XZBlockSize(this.unpaddedLength, this.uncompressedLength); 411 | } 412 | -------------------------------------------------------------------------------- /lib/src/authenticating_artifact_updater.dart: -------------------------------------------------------------------------------- 1 | // ignore_for_file: implementation_imports 2 | 3 | import 'dart:async'; 4 | import 'dart:io' as io; 5 | import 'package:archive/archive_io.dart'; 6 | import 'package:file/file.dart'; 7 | import 'package:flutterpi_tool/src/fltool/common.dart'; 8 | import 'package:flutterpi_tool/src/more_os_utils.dart'; 9 | import 'package:meta/meta.dart'; 10 | 11 | @visibleForTesting 12 | String legalizePath(Uri url, FileSystem fileSystem) { 13 | final pieces = [url.host, ...url.pathSegments]; 14 | final convertedPieces = pieces.map(_legalizeName); 15 | return fileSystem.path.joinAll(convertedPieces); 16 | } 17 | 18 | String _legalizeName(String fileName) { 19 | const substitutions = { 20 | r'@': '@@', 21 | r'/': '@s@', 22 | r'\': '@bs@', 23 | r':': '@c@', 24 | r'%': '@per@', 25 | r'*': '@ast@', 26 | r'<': '@lt@', 27 | r'>': '@gt@', 28 | r'"': '@q@', 29 | r'|': '@pip@', 30 | r'?': '@ques@', 31 | }; 32 | 33 | final replaced = [ 34 | for (final codeUnit in fileName.codeUnits) 35 | if (substitutions[String.fromCharCode(codeUnit)] case String substitute) 36 | ...substitute.codeUnits 37 | else 38 | codeUnit, 39 | ]; 40 | 41 | return String.fromCharCodes(replaced); 42 | } 43 | 44 | class AuthenticatingArtifactUpdater implements ArtifactUpdater { 45 | AuthenticatingArtifactUpdater({ 46 | required MoreOperatingSystemUtils operatingSystemUtils, 47 | required Logger logger, 48 | required FileSystem fileSystem, 49 | required Directory tempStorage, 50 | required io.HttpClient httpClient, 51 | required Platform platform, 52 | required List allowedBaseUrls, 53 | }) : _operatingSystemUtils = operatingSystemUtils, 54 | _httpClient = httpClient, 55 | _logger = logger, 56 | _fileSystem = fileSystem, 57 | _tempStorage = tempStorage, 58 | _allowedBaseUrls = allowedBaseUrls; 59 | 60 | static const int _kRetryCount = 2; 61 | 62 | final Logger _logger; 63 | final MoreOperatingSystemUtils _operatingSystemUtils; 64 | final FileSystem _fileSystem; 65 | final Directory _tempStorage; 66 | final io.HttpClient _httpClient; 67 | 68 | final List _allowedBaseUrls; 69 | 70 | @override 71 | @visibleForTesting 72 | final List downloadedFiles = []; 73 | 74 | static const Set _denylistedBasenames = { 75 | 'entitlements.txt', 76 | 'without_entitlements.txt', 77 | }; 78 | void _removeDenylistedFiles(Directory directory) { 79 | for (final FileSystemEntity entity in directory.listSync(recursive: true)) { 80 | if (entity is! File) { 81 | continue; 82 | } 83 | if (_denylistedBasenames.contains(entity.basename)) { 84 | entity.deleteSync(); 85 | } 86 | } 87 | } 88 | 89 | @override 90 | Future downloadZipArchive( 91 | String message, 92 | Uri url, 93 | Directory location, { 94 | void Function(io.HttpClientRequest)? authenticate, 95 | }) { 96 | return _downloadArchive( 97 | message, 98 | url, 99 | location, 100 | _operatingSystemUtils.unzip, 101 | authenticate: authenticate, 102 | ); 103 | } 104 | 105 | @override 106 | Future downloadZippedTarball( 107 | String message, 108 | Uri url, 109 | Directory location, { 110 | void Function(io.HttpClientRequest)? authenticate, 111 | }) { 112 | return _downloadArchive( 113 | message, 114 | url, 115 | location, 116 | _operatingSystemUtils.unpack, 117 | authenticate: authenticate, 118 | ); 119 | } 120 | 121 | Future downloadArchive( 122 | String message, 123 | Uri url, 124 | Directory location, { 125 | void Function(io.HttpClientRequest)? authenticate, 126 | ArchiveType? archiveType, 127 | Archive Function(File)? decoder, 128 | }) { 129 | return _downloadArchive( 130 | message, 131 | url, 132 | location, 133 | (File file, Directory targetDirectory) { 134 | _operatingSystemUtils.unpack( 135 | file, 136 | targetDirectory, 137 | type: archiveType, 138 | decoder: decoder, 139 | ); 140 | }, 141 | authenticate: authenticate, 142 | ); 143 | } 144 | 145 | Future _downloadArchive( 146 | String message, 147 | Uri url, 148 | Directory location, 149 | void Function(File, Directory) extractor, { 150 | void Function(io.HttpClientRequest)? authenticate, 151 | }) async { 152 | final downloadPath = legalizePath(url, _fileSystem); 153 | final tempFile = _createDownloadFile(downloadPath); 154 | 155 | var tries = _kRetryCount; 156 | while (tries > 0) { 157 | final status = _logger.startProgress(message); 158 | 159 | try { 160 | ErrorHandlingFileSystem.deleteIfExists(tempFile); 161 | if (!tempFile.parent.existsSync()) { 162 | tempFile.parent.createSync(recursive: true); 163 | } 164 | 165 | await _download(url, tempFile, status, authenticate: authenticate); 166 | 167 | if (!tempFile.existsSync()) { 168 | throw Exception('Did not find downloaded file ${tempFile.path}'); 169 | } 170 | } on Exception catch (err) { 171 | _logger.printTrace(err.toString()); 172 | tries -= 1; 173 | 174 | if (tries == 0) { 175 | throwToolExit( 176 | 'Failed to download $url. Ensure you have network connectivity and then try again.\n$err', 177 | ); 178 | } 179 | continue; 180 | } finally { 181 | status.stop(); 182 | } 183 | 184 | final destination = location.childDirectory( 185 | tempFile.fileSystem.path.basenameWithoutExtension(tempFile.path), 186 | ); 187 | 188 | ErrorHandlingFileSystem.deleteIfExists(destination, recursive: true); 189 | location.createSync(recursive: true); 190 | 191 | try { 192 | extractor(tempFile, location); 193 | } on Exception catch (err) { 194 | tries -= 1; 195 | if (tries == 0) { 196 | throwToolExit( 197 | 'Flutter could not download and/or extract $url. Ensure you have ' 198 | 'network connectivity and all of the required dependencies listed at ' 199 | 'flutter.dev/setup.\nThe original exception was: $err.', 200 | ); 201 | } 202 | 203 | ErrorHandlingFileSystem.deleteIfExists(tempFile); 204 | continue; 205 | } 206 | 207 | _removeDenylistedFiles(location); 208 | return; 209 | } 210 | } 211 | 212 | Future _download( 213 | Uri url, 214 | File file, 215 | Status status, { 216 | void Function(io.HttpClientRequest)? authenticate, 217 | }) async { 218 | final allowed = 219 | _allowedBaseUrls.any((baseUrl) => url.toString().startsWith(baseUrl)); 220 | 221 | // In tests make this a hard failure. 222 | assert( 223 | allowed, 224 | 'URL not allowed: $url\n' 225 | 'Allowed URLs must be based on one of: ${_allowedBaseUrls.join(', ')}', 226 | ); 227 | 228 | // In production, issue a warning but allow the download to proceed. 229 | if (!allowed) { 230 | status.pause(); 231 | _logger.printWarning( 232 | 'Downloading an artifact that may not be reachable in some environments (e.g. firewalled environments): $url\n' 233 | 'This should not have happened. This is likely a Flutter SDK bug. Please file an issue at https://github.com/flutter/flutter/issues/new?template=1_activation.yml'); 234 | status.resume(); 235 | } 236 | 237 | final request = await _httpClient.getUrl(url); 238 | 239 | if (authenticate != null) { 240 | try { 241 | authenticate(request); 242 | } finally { 243 | request.close().ignore(); 244 | } 245 | } 246 | 247 | final response = await request.close(); 248 | 249 | if (response.statusCode != io.HttpStatus.ok) { 250 | throw Exception(response.statusCode); 251 | } 252 | 253 | final handle = file.openSync(mode: FileMode.writeOnly); 254 | try { 255 | await for (final chunk in response) { 256 | handle.writeFromSync(chunk); 257 | } 258 | } finally { 259 | handle.closeSync(); 260 | } 261 | } 262 | 263 | File _createDownloadFile(String name) { 264 | final path = _fileSystem.path.join(_tempStorage.path, name); 265 | final file = _fileSystem.file(path); 266 | downloadedFiles.add(file); 267 | return file; 268 | } 269 | 270 | @override 271 | void removeDownloadedFiles() { 272 | for (final file in downloadedFiles) { 273 | ErrorHandlingFileSystem.deleteIfExists(file); 274 | 275 | for (var directory = file.parent; 276 | directory.absolute.path != _tempStorage.absolute.path; 277 | directory = directory.parent) { 278 | // Handle race condition when the directory is deleted before this step 279 | 280 | if (directory.existsSync() && directory.listSync().isEmpty) { 281 | ErrorHandlingFileSystem.deleteIfExists(directory, recursive: true); 282 | } 283 | } 284 | } 285 | } 286 | } 287 | -------------------------------------------------------------------------------- /lib/src/build_system/build_app.dart: -------------------------------------------------------------------------------- 1 | // ignore_for_file: avoid_print, implementation_imports 2 | 3 | import 'dart:async'; 4 | import 'package:file/file.dart'; 5 | import 'package:flutterpi_tool/src/build_system/extended_environment.dart'; 6 | import 'package:flutterpi_tool/src/build_system/targets.dart'; 7 | import 'package:flutterpi_tool/src/cache.dart'; 8 | import 'package:flutterpi_tool/src/common.dart'; 9 | import 'package:flutterpi_tool/src/devices/flutterpi_ssh/device.dart'; 10 | import 'package:flutterpi_tool/src/fltool/common.dart'; 11 | import 'package:flutterpi_tool/src/fltool/globals.dart' as globals; 12 | import 'package:flutterpi_tool/src/more_os_utils.dart'; 13 | import 'package:unified_analytics/unified_analytics.dart'; 14 | 15 | Future buildFlutterpiApp({ 16 | required String id, 17 | required FlutterpiHostPlatform host, 18 | required FlutterpiTargetPlatform target, 19 | required BuildInfo buildInfo, 20 | required FlutterpiArtifactPaths artifactPaths, 21 | required MoreOperatingSystemUtils operatingSystemUtils, 22 | FlutterProject? project, 23 | String? mainPath, 24 | String manifestPath = defaultManifestPath, 25 | String? applicationKernelFilePath, 26 | String? depfilePath, 27 | Artifacts? artifacts, 28 | BuildSystem? buildSystem, 29 | bool unoptimized = false, 30 | bool includeDebugSymbols = false, 31 | }) async { 32 | final buildDir = getBuildDirectory(); 33 | 34 | final outPath = 35 | globals.fs.path.join(buildDir, 'flutter-pi', target.toString()); 36 | final outDir = globals.fs.directory(outPath); 37 | 38 | await buildFlutterpiBundle( 39 | host: host, 40 | target: target, 41 | buildInfo: buildInfo, 42 | artifactPaths: artifactPaths, 43 | operatingSystemUtils: operatingSystemUtils, 44 | mainPath: mainPath, 45 | manifestPath: manifestPath, 46 | applicationKernelFilePath: applicationKernelFilePath, 47 | depfilePath: depfilePath, 48 | outDir: outDir, 49 | artifacts: artifacts, 50 | buildSystem: buildSystem, 51 | unoptimized: unoptimized, 52 | includeDebugSymbols: includeDebugSymbols, 53 | ); 54 | 55 | return PrebuiltFlutterpiAppBundle( 56 | id: id, 57 | name: id, 58 | displayName: id, 59 | directory: outDir, 60 | 61 | // FIXME: This should be populated by the build targets instead. 62 | binaries: [ 63 | outDir.childFile('flutter-pi'), 64 | outDir.childFile('libflutter_engine.so'), 65 | if (outDir.childFile('libflutter_engine.so.dbgsyms').existsSync()) 66 | outDir.childFile('libflutter_engine.so.dbgsyms'), 67 | ], 68 | ); 69 | } 70 | 71 | Future buildFlutterpiBundle({ 72 | required FlutterpiHostPlatform host, 73 | required FlutterpiTargetPlatform target, 74 | required BuildInfo buildInfo, 75 | required FlutterpiArtifactPaths artifactPaths, 76 | required MoreOperatingSystemUtils operatingSystemUtils, 77 | FlutterProject? project, 78 | String? mainPath, 79 | String manifestPath = defaultManifestPath, 80 | String? applicationKernelFilePath, 81 | String? depfilePath, 82 | Directory? outDir, 83 | Artifacts? artifacts, 84 | BuildSystem? buildSystem, 85 | bool unoptimized = false, 86 | bool includeDebugSymbols = false, 87 | }) async { 88 | project ??= FlutterProject.current(); 89 | mainPath ??= defaultMainPath; 90 | depfilePath ??= defaultDepfilePath; 91 | buildSystem ??= globals.buildSystem; 92 | outDir ??= globals.fs.directory(getAssetBuildDirectory()); 93 | 94 | artifacts = OverrideGenSnapshotArtifacts.fromArtifactPaths( 95 | parent: artifacts ?? globals.artifacts!, 96 | engineCacheDir: flutterpiCache.getArtifactDirectory('engine'), 97 | host: host, 98 | target: target.genericVariant, 99 | artifactPaths: artifactPaths, 100 | ); 101 | 102 | // We can still build debug for non-generic platforms of course, the correct 103 | // (generic) target must be chosen in the caller in that case. 104 | if (!target.isGeneric && buildInfo.mode == BuildMode.debug) { 105 | throw ArgumentError.value( 106 | buildInfo, 107 | 'buildInfo', 108 | 'Non-generic targets are not supported for debug mode.', 109 | ); 110 | } 111 | 112 | // If the precompiled flag was not passed, force us into debug mode. 113 | final environment = ExtendedEnvironment( 114 | projectDir: project.directory, 115 | packageConfigPath: buildInfo.packageConfigPath, 116 | outputDir: outDir, 117 | buildDir: project.dartTool.childDirectory('flutter_build'), 118 | cacheDir: globals.cache.getRoot(), 119 | flutterRootDir: globals.fs.directory(Cache.flutterRoot), 120 | engineVersion: globals.artifacts!.usesLocalArtifacts 121 | ? null 122 | : globals.flutterVersion.engineRevision, 123 | analytics: NoOpAnalytics(), 124 | defines: { 125 | if (includeDebugSymbols) kExtraGenSnapshotOptions: '--no-strip', 126 | 127 | // used by the KernelSnapshot target 128 | kTargetPlatform: getNameForTargetPlatform(TargetPlatform.linux_arm64), 129 | kTargetFile: mainPath, 130 | kDeferredComponents: 'false', 131 | ...buildInfo.toBuildSystemEnvironment(), 132 | 133 | // The flutter_tool computes the `.dart_tool/` subdir name from the 134 | // build environment hash. 135 | // Adding a flutterpi-target entry here forces different subdirs for 136 | // different target platforms. 137 | // 138 | // If we don't have this, the flutter tool will happily reuse as much as 139 | // it can, and it determines it can reuse the `app.so` from (for example) 140 | // an arm build with an arm64 build, leading to errors. 141 | 'flutterpi-target': target.shortName, 142 | 'unoptimized': unoptimized.toString(), 143 | 'debug-symbols': includeDebugSymbols.toString(), 144 | }, 145 | artifacts: artifacts, 146 | fileSystem: globals.fs, 147 | logger: globals.logger, 148 | processManager: globals.processManager, 149 | usage: globals.flutterUsage, 150 | platform: globals.platform, 151 | generateDartPluginRegistry: true, 152 | operatingSystemUtils: operatingSystemUtils, 153 | ); 154 | 155 | final buildTarget = switch (buildInfo.mode) { 156 | BuildMode.debug => DebugBundleFlutterpiAssets( 157 | flutterpiTargetPlatform: target, 158 | hostPlatform: host, 159 | unoptimized: unoptimized, 160 | artifactPaths: artifactPaths, 161 | debugSymbols: includeDebugSymbols, 162 | ), 163 | BuildMode.profile => ProfileBundleFlutterpiAssets( 164 | flutterpiTargetPlatform: target, 165 | hostPlatform: host, 166 | artifactPaths: artifactPaths, 167 | debugSymbols: includeDebugSymbols, 168 | ), 169 | BuildMode.release => ReleaseBundleFlutterpiAssets( 170 | flutterpiTargetPlatform: target, 171 | hostPlatform: host, 172 | artifactPaths: artifactPaths, 173 | debugSymbols: includeDebugSymbols, 174 | ), 175 | _ => throwToolExit('Unsupported build mode: ${buildInfo.mode}'), 176 | }; 177 | 178 | final status = globals.logger.startProgress('Building Flutter-Pi bundle...'); 179 | 180 | try { 181 | final result = await buildSystem.build(buildTarget, environment); 182 | if (!result.success) { 183 | for (final measurement in result.exceptions.values) { 184 | globals.printError( 185 | 'Target ${measurement.target} failed: ${measurement.exception}', 186 | stackTrace: measurement.fatal ? measurement.stackTrace : null, 187 | ); 188 | } 189 | 190 | throwToolExit('Failed to build bundle.'); 191 | } 192 | 193 | final depfile = Depfile(result.inputFiles, result.outputFiles); 194 | final outputDepfile = globals.fs.file(depfilePath); 195 | if (!outputDepfile.parent.existsSync()) { 196 | outputDepfile.parent.createSync(recursive: true); 197 | } 198 | 199 | final depfileService = DepfileService( 200 | fileSystem: globals.fs, 201 | logger: globals.logger, 202 | ); 203 | depfileService.writeToFile(depfile, outputDepfile); 204 | } finally { 205 | status.cancel(); 206 | } 207 | 208 | return; 209 | } 210 | -------------------------------------------------------------------------------- /lib/src/build_system/extended_environment.dart: -------------------------------------------------------------------------------- 1 | import 'package:file/file.dart'; 2 | import 'package:flutterpi_tool/src/fltool/common.dart'; 3 | import 'package:flutterpi_tool/src/more_os_utils.dart'; 4 | import 'package:process/process.dart'; 5 | import 'package:unified_analytics/unified_analytics.dart'; 6 | 7 | class ExtendedEnvironment implements Environment { 8 | factory ExtendedEnvironment({ 9 | required Directory projectDir, 10 | required String packageConfigPath, 11 | required Directory outputDir, 12 | required Directory cacheDir, 13 | required Directory flutterRootDir, 14 | required FileSystem fileSystem, 15 | required Logger logger, 16 | required Artifacts artifacts, 17 | required ProcessManager processManager, 18 | required Platform platform, 19 | required Usage usage, 20 | required Analytics analytics, 21 | String? engineVersion, 22 | required bool generateDartPluginRegistry, 23 | Directory? buildDir, 24 | required MoreOperatingSystemUtils operatingSystemUtils, 25 | Map defines = const {}, 26 | Map inputs = const {}, 27 | }) { 28 | return ExtendedEnvironment.wrap( 29 | operatingSystemUtils: operatingSystemUtils, 30 | delegate: Environment( 31 | projectDir: projectDir, 32 | packageConfigPath: packageConfigPath, 33 | outputDir: outputDir, 34 | cacheDir: cacheDir, 35 | flutterRootDir: flutterRootDir, 36 | fileSystem: fileSystem, 37 | logger: logger, 38 | artifacts: artifacts, 39 | processManager: processManager, 40 | platform: platform, 41 | usage: usage, 42 | analytics: analytics, 43 | engineVersion: engineVersion, 44 | generateDartPluginRegistry: generateDartPluginRegistry, 45 | buildDir: buildDir, 46 | defines: defines, 47 | inputs: inputs, 48 | ), 49 | ); 50 | } 51 | 52 | ExtendedEnvironment.wrap({ 53 | required this.operatingSystemUtils, 54 | required Environment delegate, 55 | }) : _delegate = delegate; 56 | 57 | final Environment _delegate; 58 | 59 | @override 60 | Analytics get analytics => _delegate.analytics; 61 | 62 | @override 63 | Artifacts get artifacts => _delegate.artifacts; 64 | 65 | @override 66 | Directory get buildDir => _delegate.buildDir; 67 | 68 | @override 69 | Directory get cacheDir => _delegate.cacheDir; 70 | 71 | @override 72 | Map get defines => _delegate.defines; 73 | 74 | @override 75 | DepfileService get depFileService => _delegate.depFileService; 76 | 77 | @override 78 | String? get engineVersion => _delegate.engineVersion; 79 | 80 | @override 81 | FileSystem get fileSystem => _delegate.fileSystem; 82 | 83 | @override 84 | Directory get flutterRootDir => _delegate.flutterRootDir; 85 | 86 | @override 87 | bool get generateDartPluginRegistry => _delegate.generateDartPluginRegistry; 88 | 89 | @override 90 | Map get inputs => _delegate.inputs; 91 | 92 | @override 93 | Logger get logger => _delegate.logger; 94 | 95 | @override 96 | Directory get outputDir => _delegate.outputDir; 97 | 98 | @override 99 | Platform get platform => _delegate.platform; 100 | 101 | @override 102 | ProcessManager get processManager => _delegate.processManager; 103 | 104 | @override 105 | Directory get projectDir => _delegate.projectDir; 106 | 107 | @override 108 | String get packageConfigPath => _delegate.packageConfigPath; 109 | 110 | @override 111 | Directory get rootBuildDir => _delegate.rootBuildDir; 112 | 113 | @override 114 | Usage get usage => _delegate.usage; 115 | 116 | final MoreOperatingSystemUtils operatingSystemUtils; 117 | } 118 | -------------------------------------------------------------------------------- /lib/src/build_system/targets.dart: -------------------------------------------------------------------------------- 1 | // ignore_for_file: avoid_print, implementation_imports 2 | 3 | import 'dart:async'; 4 | 5 | import 'package:file/file.dart'; 6 | import 'package:flutterpi_tool/src/build_system/extended_environment.dart'; 7 | import 'package:flutterpi_tool/src/cache.dart'; 8 | import 'package:flutterpi_tool/src/common.dart'; 9 | import 'package:flutterpi_tool/src/fltool/common.dart'; 10 | import 'package:flutterpi_tool/src/fltool/globals.dart'; 11 | import 'package:flutterpi_tool/src/more_os_utils.dart'; 12 | 13 | class ReleaseBundleFlutterpiAssets extends CompositeTarget { 14 | ReleaseBundleFlutterpiAssets({ 15 | required this.flutterpiTargetPlatform, 16 | required FlutterpiHostPlatform hostPlatform, 17 | required FlutterpiArtifactPaths artifactPaths, 18 | bool debugSymbols = false, 19 | }) : super([ 20 | const CopyFlutterAssets(), 21 | const CopyIcudtl(), 22 | CopyFlutterpiEngine( 23 | flutterpiTargetPlatform, 24 | buildMode: BuildMode.release, 25 | hostPlatform: hostPlatform, 26 | artifactPaths: artifactPaths, 27 | includeDebugSymbols: debugSymbols, 28 | ), 29 | CopyFlutterpiBinary( 30 | target: flutterpiTargetPlatform, 31 | buildMode: BuildMode.release, 32 | ), 33 | const FlutterpiAppElf(AotElfRelease(TargetPlatform.linux_arm64)), 34 | ]); 35 | 36 | final FlutterpiTargetPlatform flutterpiTargetPlatform; 37 | 38 | @override 39 | String get name => 40 | 'release_bundle_flutterpi_${flutterpiTargetPlatform.shortName}_assets'; 41 | } 42 | 43 | class ProfileBundleFlutterpiAssets extends CompositeTarget { 44 | ProfileBundleFlutterpiAssets({ 45 | required this.flutterpiTargetPlatform, 46 | required FlutterpiHostPlatform hostPlatform, 47 | required FlutterpiArtifactPaths artifactPaths, 48 | bool debugSymbols = false, 49 | String? flutterpiBinaryPathOverride, 50 | }) : super([ 51 | const CopyFlutterAssets(), 52 | const CopyIcudtl(), 53 | CopyFlutterpiEngine( 54 | flutterpiTargetPlatform, 55 | buildMode: BuildMode.profile, 56 | hostPlatform: hostPlatform, 57 | artifactPaths: artifactPaths, 58 | includeDebugSymbols: debugSymbols, 59 | ), 60 | CopyFlutterpiBinary( 61 | target: flutterpiTargetPlatform, 62 | buildMode: BuildMode.profile, 63 | ), 64 | const FlutterpiAppElf(AotElfProfile(TargetPlatform.linux_arm64)), 65 | ]); 66 | 67 | final FlutterpiTargetPlatform flutterpiTargetPlatform; 68 | 69 | @override 70 | String get name => 71 | 'profile_bundle_flutterpi_${flutterpiTargetPlatform.shortName}_assets'; 72 | } 73 | 74 | class DebugBundleFlutterpiAssets extends CompositeTarget { 75 | DebugBundleFlutterpiAssets({ 76 | required this.flutterpiTargetPlatform, 77 | required FlutterpiHostPlatform hostPlatform, 78 | bool unoptimized = false, 79 | bool debugSymbols = false, 80 | required FlutterpiArtifactPaths artifactPaths, 81 | String? flutterpiBinaryPathOverride, 82 | }) : super([ 83 | const CopyFlutterAssets(), 84 | const CopyIcudtl(), 85 | CopyFlutterpiEngine( 86 | flutterpiTargetPlatform, 87 | buildMode: BuildMode.debug, 88 | hostPlatform: hostPlatform, 89 | unoptimized: unoptimized, 90 | artifactPaths: artifactPaths, 91 | includeDebugSymbols: debugSymbols, 92 | ), 93 | CopyFlutterpiBinary( 94 | target: flutterpiTargetPlatform, 95 | buildMode: BuildMode.debug, 96 | ), 97 | ]); 98 | 99 | final FlutterpiTargetPlatform flutterpiTargetPlatform; 100 | 101 | @override 102 | String get name => 'debug_bundle_flutterpi_assets'; 103 | } 104 | 105 | class CopyIcudtl extends Target { 106 | const CopyIcudtl(); 107 | 108 | @override 109 | String get name => 'flutterpi_copy_icudtl'; 110 | 111 | @override 112 | List get inputs => const [ 113 | Source.artifact(Artifact.icuData), 114 | ]; 115 | 116 | @override 117 | List get outputs => const [ 118 | Source.pattern('{OUTPUT_DIR}/icudtl.dat'), 119 | ]; 120 | 121 | @override 122 | List get dependencies => []; 123 | 124 | @override 125 | Future build(Environment environment) async { 126 | final icudtl = environment.fileSystem 127 | .file(environment.artifacts.getArtifactPath(Artifact.icuData)); 128 | final outputFile = environment.outputDir.childFile('icudtl.dat'); 129 | icudtl.copySync(outputFile.path); 130 | } 131 | } 132 | 133 | extension _FileExecutableBits on File { 134 | (bool owner, bool group, bool other) getExecutableBits() { 135 | // ignore: constant_identifier_names 136 | const S_IXUSR = 00100, S_IXGRP = 00010, S_IXOTH = 00001; 137 | 138 | final stat = statSync(); 139 | final mode = stat.mode; 140 | 141 | return ( 142 | (mode & S_IXUSR) != 0, 143 | (mode & S_IXGRP) != 0, 144 | (mode & S_IXOTH) != 0 145 | ); 146 | } 147 | } 148 | 149 | void fixupExePermissions( 150 | File input, 151 | File output, { 152 | required Platform platform, 153 | required Logger logger, 154 | required MoreOperatingSystemUtils os, 155 | }) { 156 | if (platform.isLinux || platform.isMacOS) { 157 | final inputExeBits = input.getExecutableBits(); 158 | final outputExeBits = output.getExecutableBits(); 159 | 160 | if (outputExeBits != (true, true, true)) { 161 | if (inputExeBits == outputExeBits) { 162 | logger.printTrace( 163 | '${input.basename} in cache was not universally executable. ' 164 | 'Changing permissions...', 165 | ); 166 | } else { 167 | logger.printTrace( 168 | 'Copying ${input.basename} from cache to output directory did not preserve executable bit. ' 169 | 'Changing permissions...', 170 | ); 171 | } 172 | 173 | os.chmod(output, 'ugo+x'); 174 | } 175 | } 176 | } 177 | 178 | class CopyFlutterpiBinary extends Target { 179 | CopyFlutterpiBinary({ 180 | required this.target, 181 | required BuildMode buildMode, 182 | }) : flutterpiBuildType = buildMode == BuildMode.debug ? 'debug' : 'release'; 183 | 184 | final FlutterpiTargetPlatform target; 185 | final String flutterpiBuildType; 186 | 187 | @override 188 | Future build(Environment environment) async { 189 | final file = environment.cacheDir 190 | .childDirectory('artifacts') 191 | .childDirectory('flutter-pi') 192 | .childDirectory(target.triple) 193 | .childDirectory(flutterpiBuildType) 194 | .childFile('flutter-pi'); 195 | 196 | final outputFile = environment.outputDir.childFile('flutter-pi'); 197 | 198 | if (!outputFile.parent.existsSync()) { 199 | outputFile.parent.createSync(recursive: true); 200 | } 201 | file.copySync(outputFile.path); 202 | 203 | if (environment.platform.isLinux || environment.platform.isMacOS) { 204 | final inputExeBits = file.getExecutableBits(); 205 | final outputExeBits = outputFile.getExecutableBits(); 206 | 207 | if (outputExeBits != (true, true, true)) { 208 | if (inputExeBits == outputExeBits) { 209 | environment.logger.printTrace( 210 | 'flutter-pi binary in cache was not universally executable. ' 211 | 'Changing permissions...', 212 | ); 213 | } else { 214 | environment.logger.printTrace( 215 | 'Copying flutter-pi binary from cache to output directory did not preserve executable bit. ' 216 | 'Changing permissions...', 217 | ); 218 | } 219 | 220 | os.chmod(outputFile, 'ugo+x'); 221 | } 222 | } 223 | } 224 | 225 | @override 226 | List get dependencies => []; 227 | 228 | @override 229 | List get inputs => [ 230 | /// TODO: This should really be a Source.artifact(Artifact.flutterpiBinary) 231 | Source.pattern( 232 | '{CACHE_DIR}/artifacts/flutter-pi/${target.triple}/$flutterpiBuildType/flutter-pi', 233 | ), 234 | ]; 235 | 236 | @override 237 | String get name => 'copy_flutterpi'; 238 | 239 | @override 240 | List get outputs => [ 241 | Source.pattern('{OUTPUT_DIR}/flutter-pi'), 242 | ]; 243 | } 244 | 245 | class CopyFlutterpiEngine extends Target { 246 | const CopyFlutterpiEngine( 247 | this.flutterpiTargetPlatform, { 248 | required BuildMode buildMode, 249 | required FlutterpiHostPlatform hostPlatform, 250 | bool unoptimized = false, 251 | this.includeDebugSymbols = false, 252 | required FlutterpiArtifactPaths artifactPaths, 253 | }) : _buildMode = buildMode, 254 | _hostPlatform = hostPlatform, 255 | _unoptimized = unoptimized, 256 | _artifactPaths = artifactPaths; 257 | 258 | final FlutterpiTargetPlatform flutterpiTargetPlatform; 259 | final BuildMode _buildMode; 260 | final FlutterpiHostPlatform _hostPlatform; 261 | final bool _unoptimized; 262 | final FlutterpiArtifactPaths _artifactPaths; 263 | final bool includeDebugSymbols; 264 | 265 | EngineFlavor get _engineFlavor => EngineFlavor(_buildMode, _unoptimized); 266 | 267 | @override 268 | List get dependencies => []; 269 | 270 | @override 271 | List get inputs => [ 272 | _artifactPaths.getEngineSource( 273 | hostPlatform: _hostPlatform, 274 | target: flutterpiTargetPlatform, 275 | flavor: _engineFlavor, 276 | ), 277 | if (includeDebugSymbols) 278 | (_artifactPaths as FlutterpiArtifactPathsV2).getEngineDbgsymsSource( 279 | hostPlatform: _hostPlatform, 280 | target: flutterpiTargetPlatform, 281 | flavor: _engineFlavor, 282 | ), 283 | ]; 284 | 285 | @override 286 | String get name => 287 | 'copy_flutterpi_engine_${flutterpiTargetPlatform.shortName}_$_buildMode${_unoptimized ? '_unopt' : ''}'; 288 | 289 | @override 290 | List get outputs => [ 291 | const Source.pattern('{OUTPUT_DIR}/libflutter_engine.so'), 292 | if (includeDebugSymbols) 293 | const Source.pattern('{OUTPUT_DIR}/libflutter_engine.dbgsyms'), 294 | ]; 295 | 296 | @override 297 | Future build(covariant ExtendedEnvironment environment) async { 298 | final outputFile = environment.outputDir.childFile('libflutter_engine.so'); 299 | if (!outputFile.parent.existsSync()) { 300 | outputFile.parent.createSync(recursive: true); 301 | } 302 | 303 | final engine = _artifactPaths.getEngine( 304 | engineCacheDir: environment.cacheDir 305 | .childDirectory('artifacts') 306 | .childDirectory('engine'), 307 | hostPlatform: _hostPlatform, 308 | target: flutterpiTargetPlatform, 309 | flavor: _engineFlavor, 310 | ); 311 | 312 | engine.copySync(outputFile.path); 313 | 314 | fixupExePermissions( 315 | engine, 316 | outputFile, 317 | platform: environment.platform, 318 | logger: environment.logger, 319 | os: environment.operatingSystemUtils, 320 | ); 321 | 322 | if (includeDebugSymbols) { 323 | final dbgsymsOutputFile = 324 | environment.outputDir.childFile('libflutter_engine.dbgsyms'); 325 | if (!dbgsymsOutputFile.parent.existsSync()) { 326 | dbgsymsOutputFile.parent.createSync(recursive: true); 327 | } 328 | 329 | final dbgsyms = 330 | (_artifactPaths as FlutterpiArtifactPathsV2).getEngineDbgsyms( 331 | engineCacheDir: environment.cacheDir 332 | .childDirectory('artifacts') 333 | .childDirectory('engine'), 334 | target: flutterpiTargetPlatform, 335 | flavor: _engineFlavor, 336 | ); 337 | 338 | dbgsyms.copySync(dbgsymsOutputFile.path); 339 | 340 | fixupExePermissions( 341 | dbgsyms, 342 | dbgsymsOutputFile, 343 | platform: environment.platform, 344 | logger: environment.logger, 345 | os: environment.operatingSystemUtils, 346 | ); 347 | } 348 | } 349 | } 350 | 351 | /// A wrapper for AOT compilation that copies app.so into the output directory. 352 | class FlutterpiAppElf extends Target { 353 | /// Create a [FlutterpiAppElf] wrapper for [aotTarget]. 354 | const FlutterpiAppElf(this.aotTarget); 355 | 356 | /// The [AotElfBase] subclass that produces the app.so. 357 | final AotElfBase aotTarget; 358 | 359 | @override 360 | String get name => 'flutterpi_aot_bundle'; 361 | 362 | @override 363 | List get inputs => const [ 364 | Source.pattern('{BUILD_DIR}/app.so'), 365 | ]; 366 | 367 | @override 368 | List get outputs => const [ 369 | Source.pattern('{OUTPUT_DIR}/app.so'), 370 | ]; 371 | 372 | @override 373 | List get dependencies => [ 374 | aotTarget, 375 | ]; 376 | 377 | @override 378 | Future build(covariant ExtendedEnvironment environment) async { 379 | final appElf = environment.buildDir.childFile('app.so'); 380 | final outputFile = environment.outputDir.childFile('app.so'); 381 | 382 | appElf.copySync(outputFile.path); 383 | 384 | fixupExePermissions( 385 | appElf, 386 | outputFile, 387 | platform: environment.platform, 388 | logger: logger, 389 | os: environment.operatingSystemUtils, 390 | ); 391 | } 392 | } 393 | 394 | /// Copies the kernel_blob.bin to the output directory. 395 | class CopyFlutterAssets extends CopyFlutterBundle { 396 | const CopyFlutterAssets(); 397 | 398 | @override 399 | String get name => 'bundle_flutterpi_assets'; 400 | } 401 | -------------------------------------------------------------------------------- /lib/src/cli/command_runner.dart: -------------------------------------------------------------------------------- 1 | // ignore_for_file: avoid_print, implementation_imports 2 | 3 | import 'package:args/command_runner.dart'; 4 | import 'package:file/file.dart'; 5 | import 'package:flutterpi_tool/src/cli/flutterpi_command.dart'; 6 | import 'package:flutterpi_tool/src/fltool/common.dart'; 7 | 8 | class FlutterpiToolCommandRunner extends CommandRunner 9 | implements FlutterCommandRunner { 10 | FlutterpiToolCommandRunner({bool verboseHelp = false}) 11 | : super( 12 | 'flutterpi_tool', 13 | 'A tool to make development & distribution of flutter-pi apps easier.', 14 | usageLineLength: 120, 15 | ) { 16 | argParser.addOption( 17 | FlutterGlobalOptions.kPackagesOption, 18 | hide: true, 19 | help: 'Path to your "package_config.json" file.', 20 | ); 21 | 22 | argParser.addOption( 23 | FlutterGlobalOptions.kDeviceIdOption, 24 | abbr: 'd', 25 | help: 'Target device id or name (prefixes allowed).', 26 | ); 27 | 28 | argParser.addOption( 29 | FlutterGlobalOptions.kLocalWebSDKOption, 30 | hide: !verboseHelp, 31 | help: 32 | 'Name of a build output within the engine out directory, if you are building Flutter locally.\n' 33 | 'Use this to select a specific version of the web sdk if you have built multiple engine targets.\n' 34 | 'This path is relative to "--local-engine-src-path" (see above).', 35 | ); 36 | 37 | argParser.addFlag( 38 | FlutterGlobalOptions.kPrintDtd, 39 | negatable: false, 40 | help: 41 | 'Print the address of the Dart Tooling Daemon, if one is hosted by the Flutter CLI.', 42 | hide: !verboseHelp, 43 | ); 44 | } 45 | 46 | @override 47 | String get usageFooter => ''; 48 | 49 | @override 50 | List getRepoPackages() { 51 | throw UnimplementedError(); 52 | } 53 | 54 | @override 55 | List getRepoRoots() { 56 | throw UnimplementedError(); 57 | } 58 | 59 | @override 60 | void addCommand(Command command) { 61 | if (command.name != 'help' && command is! FlutterpiCommandMixin) { 62 | throw ArgumentError('Command is not a FlutterCommand: $command'); 63 | } 64 | 65 | super.addCommand(command); 66 | } 67 | 68 | @override 69 | Future run(Iterable args) { 70 | // This hacky rewriting cmdlines is also done in the upstream flutter tool. 71 | 72 | /// FIXME: This fails when options are specified. 73 | if (args.singleOrNull == 'devices') { 74 | args = ['devices', 'list']; 75 | } 76 | 77 | return super.run(args); 78 | } 79 | } 80 | 81 | abstract class FlutterpiCommand extends FlutterCommand 82 | with FlutterpiCommandMixin {} 83 | -------------------------------------------------------------------------------- /lib/src/cli/commands/build.dart: -------------------------------------------------------------------------------- 1 | // ignore_for_file: avoid_print, implementation_imports 2 | 3 | import 'dart:async'; 4 | 5 | import 'package:args/command_runner.dart'; 6 | import 'package:flutterpi_tool/src/build_system/build_app.dart'; 7 | import 'package:flutterpi_tool/src/cache.dart'; 8 | import 'package:flutterpi_tool/src/cli/command_runner.dart'; 9 | import 'package:flutterpi_tool/src/fltool/common.dart'; 10 | import 'package:flutterpi_tool/src/fltool/globals.dart' as globals; 11 | 12 | import '../../more_os_utils.dart'; 13 | import '../../common.dart'; 14 | 15 | class BuildCommand extends FlutterpiCommand { 16 | static const archs = ['arm', 'arm64', 'x64', 'riscv64']; 17 | 18 | static const cpus = ['generic', 'pi3', 'pi4']; 19 | 20 | BuildCommand({bool verboseHelp = false}) { 21 | argParser.addSeparator( 22 | 'Runtime mode options (Defaults to debug. At most one can be specified)', 23 | ); 24 | 25 | usesEngineFlavorOption(); 26 | 27 | argParser 28 | ..addSeparator('Build options') 29 | ..addFlag( 30 | 'tree-shake-icons', 31 | help: 32 | 'Tree shake icon fonts so that only glyphs used by the application remain.', 33 | ); 34 | 35 | usesDebugSymbolsOption(); 36 | 37 | // add --dart-define, --dart-define-from-file options 38 | usesDartDefineOption(); 39 | usesTargetOption(); 40 | usesCustomCache(verboseHelp: verboseHelp); 41 | 42 | argParser 43 | ..addSeparator('Target options') 44 | ..addOption( 45 | 'arch', 46 | allowed: archs, 47 | defaultsTo: 'arm', 48 | help: 'The target architecture to build for.', 49 | valueHelp: 'target arch', 50 | allowedHelp: { 51 | 'arm': 'Build for 32-bit ARM. (armv7-linux-gnueabihf)', 52 | 'arm64': 'Build for 64-bit ARM. (aarch64-linux-gnu)', 53 | 'x64': 'Build for x86-64. (x86_64-linux-gnu)', 54 | 'riscv64': 'Build for 64-bit RISC-V. (riscv64-linux-gnu)', 55 | }, 56 | ) 57 | ..addOption( 58 | 'cpu', 59 | allowed: cpus, 60 | defaultsTo: 'generic', 61 | help: 62 | 'If specified, uses an engine tuned for the given CPU. An engine tuned for one CPU will likely not work on other CPUs.', 63 | valueHelp: 'target cpu', 64 | allowedHelp: { 65 | 'generic': 66 | 'Don\'t use a tuned engine. The generic engine will work on all CPUs of the specified architecture.', 67 | 'pi3': 68 | 'Use a Raspberry Pi 3 tuned engine. Compatible with arm and arm64. (-mcpu=cortex-a53+nocrypto -mtune=cortex-a53)', 69 | 'pi4': 70 | 'Use a Raspberry Pi 4 tuned engine. Compatible with arm and arm64. (-mcpu=cortex-a72+nocrypto -mtune=cortex-a72)', 71 | }, 72 | ); 73 | } 74 | 75 | @override 76 | String get name => 'build'; 77 | 78 | @override 79 | String get description => 'Builds a flutter-pi asset bundle.'; 80 | 81 | @override 82 | String get category => FlutterCommandCategory.project; 83 | 84 | @override 85 | FlutterpiToolCommandRunner? get runner => 86 | super.runner as FlutterpiToolCommandRunner; 87 | 88 | EngineFlavor get defaultFlavor => EngineFlavor.debug; 89 | 90 | int exitWithUsage({int exitCode = 1, String? errorMessage, String? usage}) { 91 | if (errorMessage != null) { 92 | print(errorMessage); 93 | } 94 | 95 | if (usage != null) { 96 | print(usage); 97 | } else { 98 | printUsage(); 99 | } 100 | return exitCode; 101 | } 102 | 103 | FlutterpiTargetPlatform getTargetPlatform() { 104 | return switch ((stringArg('arch'), stringArg('cpu'))) { 105 | ('arm', 'generic') => FlutterpiTargetPlatform.genericArmV7, 106 | ('arm', 'pi3') => FlutterpiTargetPlatform.pi3, 107 | ('arm', 'pi4') => FlutterpiTargetPlatform.pi4, 108 | ('arm64', 'generic') => FlutterpiTargetPlatform.genericAArch64, 109 | ('arm64', 'pi3') => FlutterpiTargetPlatform.pi3_64, 110 | ('arm64', 'pi4') => FlutterpiTargetPlatform.pi4_64, 111 | ('x64', 'generic') => FlutterpiTargetPlatform.genericX64, 112 | ('riscv64', 'generic') => FlutterpiTargetPlatform.genericRiscv64, 113 | (final arch, final cpu) => throw UsageException( 114 | 'Unsupported target arch & cpu combination: architecture "$arch" is not supported for cpu "$cpu"', 115 | usage, 116 | ), 117 | }; 118 | } 119 | 120 | @override 121 | Future runCommand() async { 122 | final buildMode = getBuildMode(); 123 | final flavor = getEngineFlavor(); 124 | final debugSymbols = getIncludeDebugSymbols(); 125 | final buildInfo = await getBuildInfo(); 126 | 127 | final os = switch (globals.os) { 128 | MoreOperatingSystemUtils os => os, 129 | _ => throw StateError( 130 | 'Operating system utils is not an FPiOperatingSystemUtils', 131 | ), 132 | }; 133 | 134 | // for windows arm64, darwin arm64, we just use the x64 variant 135 | final host = switch (os.fpiHostPlatform) { 136 | FlutterpiHostPlatform.windowsARM64 => FlutterpiHostPlatform.windowsX64, 137 | FlutterpiHostPlatform.darwinARM64 => FlutterpiHostPlatform.darwinX64, 138 | FlutterpiHostPlatform other => other 139 | }; 140 | 141 | var targetPlatform = getTargetPlatform(); 142 | 143 | if (buildMode == BuildMode.debug && !targetPlatform.isGeneric) { 144 | globals.logger.printTrace( 145 | 'Non-generic target platform ($targetPlatform) is not supported ' 146 | 'for debug mode, using generic variant ' 147 | '${targetPlatform.genericVariant}.', 148 | ); 149 | targetPlatform = targetPlatform.genericVariant; 150 | } 151 | 152 | // update the cached flutter-pi artifacts 153 | await flutterpiCache.updateAll( 154 | const {DevelopmentArtifact.universal}, 155 | host: host, 156 | offline: false, 157 | flutterpiPlatforms: {targetPlatform, targetPlatform.genericVariant}, 158 | runtimeModes: {buildMode}, 159 | engineFlavors: {flavor}, 160 | includeDebugSymbols: debugSymbols, 161 | ); 162 | 163 | // actually build the flutter bundle 164 | try { 165 | await buildFlutterpiBundle( 166 | host: host, 167 | target: targetPlatform, 168 | buildInfo: buildInfo, 169 | mainPath: targetFile, 170 | artifactPaths: flutterpiCache.artifactPaths, 171 | operatingSystemUtils: os, 172 | 173 | // for `--debug-unoptimized` build mode 174 | unoptimized: flavor.unoptimized, 175 | includeDebugSymbols: debugSymbols, 176 | ); 177 | } on Exception catch (e, st) { 178 | globals.logger.printError('Failed to build Flutter-Pi bundle: $e\n$st'); 179 | return FlutterCommandResult.fail(); 180 | } 181 | 182 | return FlutterCommandResult.success(); 183 | } 184 | } 185 | -------------------------------------------------------------------------------- /lib/src/cli/commands/devices.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | 3 | import 'package:args/command_runner.dart'; 4 | import 'package:flutterpi_tool/src/cli/command_runner.dart'; 5 | import 'package:flutterpi_tool/src/fltool/common.dart'; 6 | import 'package:flutterpi_tool/src/fltool/globals.dart' as globals; 7 | import 'package:flutterpi_tool/src/config.dart'; 8 | import 'package:flutterpi_tool/src/devices/flutterpi_ssh/ssh_utils.dart'; 9 | 10 | String pluralize(String word, int count) => count == 1 ? word : '${word}s'; 11 | 12 | class FlutterpiToolDevicesCommandOutput { 13 | factory FlutterpiToolDevicesCommandOutput({ 14 | required Platform platform, 15 | required Logger logger, 16 | DeviceManager? deviceManager, 17 | Duration? deviceDiscoveryTimeout, 18 | DeviceConnectionInterface? deviceConnectionInterface, 19 | }) { 20 | if (platform.isMacOS) { 21 | return FlutterpiToolDevicesCommandOutputWithExtendedWirelessDeviceDiscovery( 22 | logger: logger, 23 | deviceManager: deviceManager, 24 | deviceDiscoveryTimeout: deviceDiscoveryTimeout, 25 | deviceConnectionInterface: deviceConnectionInterface, 26 | ); 27 | } 28 | return FlutterpiToolDevicesCommandOutput._private( 29 | logger: logger, 30 | deviceManager: deviceManager, 31 | deviceDiscoveryTimeout: deviceDiscoveryTimeout, 32 | deviceConnectionInterface: deviceConnectionInterface, 33 | ); 34 | } 35 | 36 | FlutterpiToolDevicesCommandOutput._private({ 37 | required Logger logger, 38 | required DeviceManager? deviceManager, 39 | required this.deviceDiscoveryTimeout, 40 | required this.deviceConnectionInterface, 41 | }) : _deviceManager = deviceManager, 42 | _logger = logger; 43 | 44 | final DeviceManager? _deviceManager; 45 | final Logger _logger; 46 | final Duration? deviceDiscoveryTimeout; 47 | final DeviceConnectionInterface? deviceConnectionInterface; 48 | 49 | bool get _includeAttachedDevices => 50 | deviceConnectionInterface == null || 51 | deviceConnectionInterface == DeviceConnectionInterface.attached; 52 | 53 | bool get _includeWirelessDevices => 54 | deviceConnectionInterface == null || 55 | deviceConnectionInterface == DeviceConnectionInterface.wireless; 56 | 57 | Future> _getAttachedDevices(DeviceManager deviceManager) async { 58 | if (!_includeAttachedDevices) { 59 | return []; 60 | } 61 | return deviceManager.getAllDevices( 62 | filter: DeviceDiscoveryFilter( 63 | deviceConnectionInterface: DeviceConnectionInterface.attached, 64 | ), 65 | ); 66 | } 67 | 68 | Future> _getWirelessDevices(DeviceManager deviceManager) async { 69 | if (!_includeWirelessDevices) { 70 | return []; 71 | } 72 | return deviceManager.getAllDevices( 73 | filter: DeviceDiscoveryFilter( 74 | deviceConnectionInterface: DeviceConnectionInterface.wireless, 75 | ), 76 | ); 77 | } 78 | 79 | Future findAndOutputAllTargetDevices({required bool machine}) async { 80 | List attachedDevices = []; 81 | List wirelessDevices = []; 82 | final DeviceManager? deviceManager = _deviceManager; 83 | if (deviceManager != null) { 84 | // Refresh the cache and then get the attached and wireless devices from 85 | // the cache. 86 | await deviceManager.refreshAllDevices(timeout: deviceDiscoveryTimeout); 87 | attachedDevices = await _getAttachedDevices(deviceManager); 88 | wirelessDevices = await _getWirelessDevices(deviceManager); 89 | } 90 | final List allDevices = attachedDevices + wirelessDevices; 91 | 92 | if (machine) { 93 | await printDevicesAsJson(allDevices); 94 | return; 95 | } 96 | 97 | if (allDevices.isEmpty) { 98 | _logger.printStatus('No authorized devices detected.'); 99 | } else { 100 | if (attachedDevices.isNotEmpty) { 101 | _logger.printStatus( 102 | 'Found ${attachedDevices.length} connected ${pluralize('device', attachedDevices.length)}:', 103 | ); 104 | await Device.printDevices(attachedDevices, _logger, prefix: ' '); 105 | } 106 | if (wirelessDevices.isNotEmpty) { 107 | if (attachedDevices.isNotEmpty) { 108 | _logger.printStatus(''); 109 | } 110 | _logger.printStatus( 111 | 'Found ${wirelessDevices.length} wirelessly connected ${pluralize('device', wirelessDevices.length)}:', 112 | ); 113 | await Device.printDevices(wirelessDevices, _logger, prefix: ' '); 114 | } 115 | } 116 | await _printDiagnostics(foundAny: allDevices.isNotEmpty); 117 | } 118 | 119 | Future _printDiagnostics({required bool foundAny}) async { 120 | final status = StringBuffer(); 121 | status.writeln(); 122 | 123 | final diagnostics = 124 | await _deviceManager?.getDeviceDiagnostics() ?? []; 125 | if (diagnostics.isNotEmpty) { 126 | for (final diagnostic in diagnostics) { 127 | status.writeln(diagnostic); 128 | status.writeln(); 129 | } 130 | } 131 | status.write( 132 | 'If you expected ${foundAny ? 'another' : 'a'} device to be detected, try increasing the time to wait for connected devices by using the "flutterpi_tool devices list" command with the "--${FlutterOptions.kDeviceTimeout}" flag.', 133 | ); 134 | _logger.printStatus(status.toString()); 135 | } 136 | 137 | Future printDevicesAsJson(List devices) async { 138 | _logger.printStatus( 139 | const JsonEncoder.withIndent(' ') 140 | .convert(await Future.wait(devices.map((d) => d.toJson()))), 141 | ); 142 | } 143 | } 144 | 145 | const String _checkingForWirelessDevicesMessage = 146 | 'Checking for wireless devices...'; 147 | const String _noAttachedCheckForWireless = 148 | 'No devices found yet. Checking for wireless devices...'; 149 | const String _noWirelessDevicesFoundMessage = 'No wireless devices were found.'; 150 | 151 | class FlutterpiToolDevicesCommandOutputWithExtendedWirelessDeviceDiscovery 152 | extends FlutterpiToolDevicesCommandOutput { 153 | FlutterpiToolDevicesCommandOutputWithExtendedWirelessDeviceDiscovery({ 154 | required super.logger, 155 | super.deviceManager, 156 | super.deviceDiscoveryTimeout, 157 | super.deviceConnectionInterface, 158 | }) : super._private(); 159 | 160 | @override 161 | Future findAndOutputAllTargetDevices({required bool machine}) async { 162 | // When a user defines the timeout or filters to only attached devices, 163 | // use the super function that does not do longer wireless device discovery. 164 | if (deviceDiscoveryTimeout != null || 165 | deviceConnectionInterface == DeviceConnectionInterface.attached) { 166 | return super.findAndOutputAllTargetDevices(machine: machine); 167 | } 168 | 169 | if (machine) { 170 | final List devices = await _deviceManager?.refreshAllDevices( 171 | filter: DeviceDiscoveryFilter( 172 | deviceConnectionInterface: deviceConnectionInterface, 173 | ), 174 | timeout: DeviceManager.minimumWirelessDeviceDiscoveryTimeout, 175 | ) ?? 176 | []; 177 | await printDevicesAsJson(devices); 178 | return; 179 | } 180 | 181 | final Future? extendedWirelessDiscovery = 182 | _deviceManager?.refreshExtendedWirelessDeviceDiscoverers( 183 | timeout: DeviceManager.minimumWirelessDeviceDiscoveryTimeout, 184 | ); 185 | 186 | List attachedDevices = []; 187 | final DeviceManager? deviceManager = _deviceManager; 188 | if (deviceManager != null) { 189 | attachedDevices = await _getAttachedDevices(deviceManager); 190 | } 191 | 192 | // Number of lines to clear starts at 1 because it's inclusive of the line 193 | // the cursor is on, which will be blank for this use case. 194 | int numLinesToClear = 1; 195 | 196 | // Display list of attached devices. 197 | if (attachedDevices.isNotEmpty) { 198 | _logger.printStatus( 199 | 'Found ${attachedDevices.length} connected ${pluralize('device', attachedDevices.length)}:', 200 | ); 201 | await Device.printDevices(attachedDevices, _logger, prefix: ' '); 202 | _logger.printStatus(''); 203 | numLinesToClear += 1; 204 | } 205 | 206 | // Display waiting message. 207 | if (attachedDevices.isEmpty && _includeAttachedDevices) { 208 | _logger.printStatus(_noAttachedCheckForWireless); 209 | } else { 210 | _logger.printStatus(_checkingForWirelessDevicesMessage); 211 | } 212 | numLinesToClear += 1; 213 | 214 | final Status waitingStatus = _logger.startSpinner(); 215 | await extendedWirelessDiscovery; 216 | List wirelessDevices = []; 217 | if (deviceManager != null) { 218 | wirelessDevices = await _getWirelessDevices(deviceManager); 219 | } 220 | waitingStatus.stop(); 221 | 222 | final Terminal terminal = _logger.terminal; 223 | if (_logger.isVerbose && _includeAttachedDevices) { 224 | // Reprint the attach devices. 225 | if (attachedDevices.isNotEmpty) { 226 | _logger.printStatus( 227 | '\nFound ${attachedDevices.length} connected ${pluralize('device', attachedDevices.length)}:', 228 | ); 229 | await Device.printDevices(attachedDevices, _logger, prefix: ' '); 230 | } 231 | } else if (terminal.supportsColor && terminal is AnsiTerminal) { 232 | _logger.printStatus( 233 | terminal.clearLines(numLinesToClear), 234 | newline: false, 235 | ); 236 | } 237 | 238 | if (attachedDevices.isNotEmpty || !_logger.terminal.supportsColor) { 239 | _logger.printStatus(''); 240 | } 241 | 242 | if (wirelessDevices.isEmpty) { 243 | if (attachedDevices.isEmpty) { 244 | // No wireless or attached devices were found. 245 | _logger.printStatus('No authorized devices detected.'); 246 | } else { 247 | // Attached devices found, wireless devices not found. 248 | _logger.printStatus(_noWirelessDevicesFoundMessage); 249 | } 250 | } else { 251 | // Display list of wireless devices. 252 | _logger.printStatus( 253 | 'Found ${wirelessDevices.length} wirelessly connected ${pluralize('device', wirelessDevices.length)}:', 254 | ); 255 | await Device.printDevices(wirelessDevices, _logger, prefix: ' '); 256 | } 257 | await _printDiagnostics( 258 | foundAny: wirelessDevices.isNotEmpty || attachedDevices.isNotEmpty, 259 | ); 260 | } 261 | } 262 | 263 | // A diagnostic message, reported to the user when a problem is detected. 264 | abstract class Diagnostic { 265 | const Diagnostic(); 266 | 267 | const factory Diagnostic.fixCommand({ 268 | required String title, 269 | required String message, 270 | required String command, 271 | }) = FixCommandDiagnostic; 272 | 273 | String get title; 274 | 275 | void printMessage(Logger logger); 276 | 277 | static void printList( 278 | Iterable diagnostics, { 279 | required Logger logger, 280 | }) { 281 | for (final (index, diagnostic) in diagnostics.indexed) { 282 | logger.printStatus('${index + 1}. ${diagnostic.title}'); 283 | diagnostic.printMessage(logger); 284 | } 285 | } 286 | } 287 | 288 | // A diagnostic message that includes a command to fix the issue. 289 | class FixCommandDiagnostic extends Diagnostic { 290 | const FixCommandDiagnostic({ 291 | required this.title, 292 | required this.message, 293 | required this.command, 294 | }); 295 | 296 | @override 297 | final String title; 298 | final String message; 299 | final String command; 300 | 301 | @override 302 | void printMessage(Logger logger, {int indent = 3}) { 303 | logger.printStatus(message, indent: indent); 304 | logger.printStatus(command, indent: indent + 2, emphasis: true); 305 | } 306 | } 307 | 308 | class DevicesCommand extends FlutterpiCommand { 309 | DevicesCommand({bool verboseHelp = false}) { 310 | addSubcommand(DevicesAddCommand()); 311 | addSubcommand(DevicesRemoveCommand()); 312 | addSubcommand(DevicesListCommand()); 313 | } 314 | 315 | @override 316 | String get description => 'List & manage flutterpi_tool devices.'; 317 | 318 | @override 319 | final String category = FlutterCommandCategory.tools; 320 | 321 | @override 322 | String get name => 'devices'; 323 | 324 | @override 325 | String get invocation => 326 | '${runner!.executableName} devices [subcommand] [arguments]'; 327 | 328 | @override 329 | String? get usageFooter => 330 | 'If no subcommand is specified, the attached devices will be listed.'; 331 | 332 | @override 333 | Future runCommand() async { 334 | throw UnimplementedError(); 335 | } 336 | } 337 | 338 | class DevicesListCommand extends FlutterpiCommand { 339 | DevicesListCommand() { 340 | usesDeviceTimeoutOption(); 341 | usesDeviceConnectionOption(); 342 | 343 | usesDeviceManager(); 344 | } 345 | 346 | @override 347 | String get description => 'List flutterpi_tool device.'; 348 | 349 | @override 350 | String get name => 'list'; 351 | 352 | @override 353 | Future runCommand() async { 354 | if (globals.doctor?.canListAnything != true) { 355 | throwToolExit( 356 | "Unable to locate a development device.", 357 | exitCode: 1, 358 | ); 359 | } 360 | 361 | final output = FlutterpiToolDevicesCommandOutput( 362 | platform: globals.platform, 363 | logger: globals.logger, 364 | deviceManager: globals.deviceManager, 365 | deviceDiscoveryTimeout: deviceDiscoveryTimeout, 366 | deviceConnectionInterface: deviceConnectionInterface, 367 | ); 368 | 369 | await output.findAndOutputAllTargetDevices(machine: false); 370 | 371 | return FlutterCommandResult.success(); 372 | } 373 | } 374 | 375 | class DevicesAddCommand extends FlutterpiCommand { 376 | DevicesAddCommand() { 377 | argParser.addOption( 378 | 'type', 379 | abbr: 't', 380 | allowed: ['ssh'], 381 | help: 'The type of device to add.', 382 | valueHelp: 'type', 383 | defaultsTo: 'ssh', 384 | ); 385 | 386 | argParser.addOption( 387 | 'id', 388 | help: 'The id of the device to be created. If not specified, this is ' 389 | 'the hostname part of the [user@]hostname argument.', 390 | valueHelp: 'id', 391 | ); 392 | 393 | argParser.addOption( 394 | 'ssh-executable', 395 | help: 'The path to the ssh executable.', 396 | valueHelp: 'path', 397 | ); 398 | 399 | argParser.addOption( 400 | 'remote-install-path', 401 | help: 'The path to install flutter apps on the remote device.', 402 | valueHelp: 'path', 403 | ); 404 | 405 | argParser.addFlag( 406 | 'force', 407 | abbr: 'f', 408 | help: 'Don\'t verify the configured device before adding it.', 409 | ); 410 | 411 | usesDisplaySizeArg(); 412 | usesDummyDisplayArg(); 413 | usesSshRemoteNonOptionArg(); 414 | } 415 | 416 | @override 417 | String get description => 'Add a new flutterpi_tool device.'; 418 | 419 | @override 420 | String get name => 'add'; 421 | 422 | @override 423 | String get invocation => 'flutterpi_tool devices add <[user@]hostname>'; 424 | 425 | @override 426 | Future runCommand() async { 427 | final remote = sshRemote; 428 | 429 | final id = stringArg('id') ?? sshHostname; 430 | 431 | final type = stringArg('type'); 432 | if (type != 'ssh') { 433 | throw UsageException('Unsupported device type: $type', usage); 434 | } 435 | 436 | final sshExecutable = stringArg('ssh-executable'); 437 | final remoteInstallPath = stringArg('remote-install-path'); 438 | final force = boolArg('force'); 439 | final displaySize = this.displaySize; 440 | 441 | final flutterpiToolConfig = globals.flutterPiToolConfig; 442 | if (flutterpiToolConfig.containsDevice(id)) { 443 | globals.printError('flutterpi_tool device with id "$id" already exists.'); 444 | return FlutterCommandResult.fail(); 445 | } 446 | 447 | final diagnostics = []; 448 | 449 | if (!force) { 450 | final ssh = SshUtils( 451 | processUtils: globals.processUtils, 452 | sshExecutable: sshExecutable ?? 'ssh', 453 | defaultRemote: remote, 454 | ); 455 | 456 | final connected = await ssh.tryConnect(timeout: Duration(seconds: 5)); 457 | if (!connected) { 458 | globals.printError( 459 | 'Connecting to device failed. Make sure the device is reachable ' 460 | 'and public-key authentication is set up correctly. If you wish to add ' 461 | 'the device anyway, use --force.', 462 | ); 463 | return FlutterCommandResult.fail(); 464 | } 465 | 466 | final hasPermissions = 467 | await ssh.remoteUserBelongsToGroups(['video', 'input', 'render']); 468 | if (!hasPermissions) { 469 | final addGroupsCommand = ssh 470 | .buildSshCommand( 471 | interactive: null, 472 | allocateTTY: true, 473 | command: r"'sudo usermod -aG video,input,render $USER'", 474 | ) 475 | .join(' '); 476 | 477 | diagnostics.add( 478 | Diagnostic.fixCommand( 479 | title: 480 | 'The remote user needs permission to use display and input devices.', 481 | message: 482 | 'To add the necessary permissions, run the following command in your terminal.\n' 483 | 'NOTE: This gives any app running as the remote user access to the display, input and render devices. ' 484 | 'If you\'re running untrusted code, consider the security implications.\n', 485 | command: addGroupsCommand, 486 | ), 487 | ); 488 | } 489 | } 490 | 491 | globals.flutterPiToolConfig.addDevice( 492 | DeviceConfigEntry( 493 | id: id, 494 | sshExecutable: sshExecutable, 495 | sshRemote: remote, 496 | remoteInstallPath: remoteInstallPath, 497 | displaySizeMillimeters: displaySize, 498 | useDummyDisplay: useDummyDisplay, 499 | dummyDisplaySize: dummyDisplaySize, 500 | ), 501 | ); 502 | 503 | if (diagnostics.isNotEmpty) { 504 | globals.printWarning( 505 | 'The device "$id" has been added, but additional steps are necessary to be able to run Flutter apps.', 506 | color: TerminalColor.yellow, 507 | ); 508 | Diagnostic.printList(diagnostics, logger: globals.logger); 509 | } else { 510 | globals.printStatus('Device "$id" has been added successfully.'); 511 | } 512 | 513 | return FlutterCommandResult.success(); 514 | } 515 | } 516 | 517 | class DevicesRemoveCommand extends FlutterpiCommand { 518 | DevicesRemoveCommand() { 519 | usesSshRemoteNonOptionArg(); 520 | } 521 | 522 | @override 523 | String get description => 'Remove a flutterpi_tool device.'; 524 | 525 | @override 526 | String get name => 'remove'; 527 | 528 | @override 529 | List get aliases => ['rm']; 530 | 531 | @override 532 | Future runCommand() async { 533 | final id = sshHostname; 534 | 535 | final flutterpiToolConfig = globals.flutterPiToolConfig; 536 | 537 | if (!flutterpiToolConfig.containsDevice(id)) { 538 | globals.printError('No flutterpi_tool device with id "$id" found.'); 539 | return FlutterCommandResult.fail(); 540 | } 541 | 542 | flutterpiToolConfig.removeDevice(id); 543 | return FlutterCommandResult.success(); 544 | } 545 | } 546 | -------------------------------------------------------------------------------- /lib/src/cli/commands/precache.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutterpi_tool/src/cache.dart'; 2 | import 'package:flutterpi_tool/src/cli/command_runner.dart'; 3 | import 'package:flutterpi_tool/src/common.dart'; 4 | import 'package:flutterpi_tool/src/fltool/common.dart'; 5 | 6 | import 'package:flutterpi_tool/src/fltool/globals.dart' as globals; 7 | import 'package:flutterpi_tool/src/more_os_utils.dart'; 8 | 9 | class PrecacheCommand extends FlutterpiCommand { 10 | PrecacheCommand({bool verboseHelp = false}) { 11 | usesCustomCache(verboseHelp: verboseHelp); 12 | } 13 | 14 | @override 15 | String get name => 'precache'; 16 | 17 | @override 18 | String get description => 19 | 'Populate the flutterpi_tool\'s cache of binary artifacts.'; 20 | 21 | @override 22 | final String category = 'Flutter-Pi Tool'; 23 | 24 | @override 25 | Future runCommand() async { 26 | final os = switch (globals.os) { 27 | MoreOperatingSystemUtils os => os, 28 | _ => throw StateError( 29 | 'Operating system utils is not an FPiOperatingSystemUtils', 30 | ), 31 | }; 32 | 33 | final host = switch (os.fpiHostPlatform) { 34 | FlutterpiHostPlatform.windowsARM64 => FlutterpiHostPlatform.windowsX64, 35 | FlutterpiHostPlatform.darwinARM64 => FlutterpiHostPlatform.darwinX64, 36 | FlutterpiHostPlatform other => other 37 | }; 38 | 39 | // update the cached flutter-pi artifacts 40 | await flutterpiCache.updateAll( 41 | const {DevelopmentArtifact.universal}, 42 | offline: false, 43 | host: host, 44 | flutterpiPlatforms: FlutterpiTargetPlatform.values.toSet(), 45 | engineFlavors: EngineFlavor.values.toSet(), 46 | includeDebugSymbols: true, 47 | ); 48 | 49 | return FlutterCommandResult.success(); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /lib/src/cli/commands/run.dart: -------------------------------------------------------------------------------- 1 | // ignore_for_file: implementation_imports 2 | 3 | import 'package:flutter_tools/src/commands/run.dart' as fltool; 4 | import 'package:flutter_tools/src/device.dart'; 5 | import 'package:flutter_tools/src/runner/flutter_command.dart'; 6 | 7 | import 'package:flutterpi_tool/src/cli/flutterpi_command.dart'; 8 | import 'package:meta/meta.dart'; 9 | 10 | class RunCommand extends fltool.RunCommand with FlutterpiCommandMixin { 11 | RunCommand() { 12 | usesDeviceManager(); 13 | usesEngineFlavorOption(); 14 | usesDebugSymbolsOption(); 15 | } 16 | 17 | @protected 18 | @override 19 | Future createDebuggingOptions(bool webMode) async { 20 | final buildInfo = await getBuildInfo(); 21 | 22 | if (buildInfo.mode.isRelease) { 23 | return DebuggingOptions.disabled(buildInfo); 24 | } else { 25 | return DebuggingOptions.enabled(buildInfo); 26 | } 27 | } 28 | 29 | @override 30 | void addBuildModeFlags({ 31 | required bool verboseHelp, 32 | bool defaultToRelease = true, 33 | bool excludeDebug = false, 34 | bool excludeRelease = false, 35 | }) { 36 | // noop 37 | } 38 | 39 | @override 40 | Future runCommand() async { 41 | await populateCache(); 42 | 43 | return super.runCommand(); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /lib/src/cli/commands/test.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutterpi_tool/src/cli/flutterpi_command.dart'; 2 | import 'package:flutterpi_tool/src/fltool/common.dart' as fltool; 3 | 4 | class TestCommand extends fltool.TestCommand with FlutterpiCommandMixin { 5 | TestCommand() { 6 | usesDeviceManager(); 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /lib/src/cli/flutterpi_command.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | import 'dart:io'; 3 | import 'package:args/command_runner.dart'; 4 | import 'package:file/file.dart'; 5 | import 'package:flutterpi_tool/src/application_package_factory.dart'; 6 | import 'package:flutterpi_tool/src/cache.dart'; 7 | import 'package:flutterpi_tool/src/common.dart'; 8 | import 'package:flutterpi_tool/src/devices/device_manager.dart'; 9 | import 'package:flutterpi_tool/src/devices/flutterpi_ssh/device.dart'; 10 | import 'package:flutterpi_tool/src/fltool/common.dart'; 11 | import 'package:flutterpi_tool/src/fltool/context_runner.dart' as fltool; 12 | import 'package:flutterpi_tool/src/fltool/globals.dart' as globals; 13 | import 'package:flutterpi_tool/src/config.dart'; 14 | import 'package:flutterpi_tool/src/github.dart'; 15 | import 'package:flutterpi_tool/src/more_os_utils.dart'; 16 | import 'package:flutterpi_tool/src/devices/flutterpi_ssh/ssh_utils.dart'; 17 | import 'package:flutterpi_tool/src/shutdown_hooks.dart'; 18 | import 'package:github/github.dart' as gh; 19 | import 'package:http/http.dart' as http; 20 | import 'package:http/io_client.dart' as http; 21 | import 'package:process/process.dart'; 22 | 23 | mixin FlutterpiCommandMixin on FlutterCommand { 24 | MyGithub createGithub({http.Client? httpClient}) { 25 | httpClient ??= http.Client(); 26 | 27 | final String? token; 28 | if (argParser.options.containsKey('github-artifacts-auth-token')) { 29 | token = stringArg('github-artifacts-auth-token'); 30 | } else { 31 | token = null; 32 | } 33 | 34 | return MyGithub.caching( 35 | httpClient: httpClient, 36 | auth: token != null ? gh.Authentication.bearerToken(token) : null, 37 | ); 38 | } 39 | 40 | FlutterpiCache createCustomCache({ 41 | required FileSystem fs, 42 | required ShutdownHooks shutdownHooks, 43 | required Logger logger, 44 | required Platform platform, 45 | required MoreOperatingSystemUtils os, 46 | required FlutterProjectFactory projectFactory, 47 | required ProcessManager processManager, 48 | http.Client? httpClient, 49 | }) { 50 | final repo = stringArg('github-artifacts-repo'); 51 | final runId = stringArg('github-artifacts-runid'); 52 | final githubEngineHash = stringArg('github-artifacts-engine-version'); 53 | 54 | if (runId != null) { 55 | return FlutterpiCache.fromWorkflow( 56 | hooks: shutdownHooks, 57 | logger: logger, 58 | fileSystem: fs, 59 | platform: platform, 60 | osUtils: os, 61 | projectFactory: projectFactory, 62 | processManager: processManager, 63 | repo: repo != null ? gh.RepositorySlug.full(repo) : null, 64 | runId: runId, 65 | availableEngineVersion: githubEngineHash, 66 | github: createGithub(httpClient: httpClient), 67 | ); 68 | } else { 69 | return FlutterpiCache( 70 | hooks: shutdownHooks, 71 | logger: logger, 72 | fileSystem: fs, 73 | platform: platform, 74 | osUtils: os, 75 | projectFactory: projectFactory, 76 | processManager: processManager, 77 | repo: repo != null ? gh.RepositorySlug.full(repo) : null, 78 | github: createGithub(httpClient: httpClient), 79 | ); 80 | } 81 | } 82 | 83 | Logger createLogger() { 84 | final factory = LoggerFactory( 85 | outputPreferences: globals.outputPreferences, 86 | terminal: globals.terminal, 87 | stdio: globals.stdio, 88 | ); 89 | 90 | return factory.createLogger( 91 | daemon: false, 92 | machine: false, 93 | verbose: boolArg('verbose', global: true), 94 | prefixedErrors: false, 95 | windows: globals.platform.isWindows, 96 | ); 97 | } 98 | 99 | void usesSshRemoteNonOptionArg({bool mandatory = true}) { 100 | assert(mandatory); 101 | } 102 | 103 | void usesDisplaySizeArg() { 104 | argParser.addOption( 105 | 'display-size', 106 | help: 107 | 'The physical size of the device display in millimeters. This is used to calculate the device pixel ratio.', 108 | valueHelp: 'width x height', 109 | ); 110 | } 111 | 112 | void usesDummyDisplayArg() { 113 | argParser.addFlag( 114 | 'dummy-display', 115 | help: 116 | 'Simulate a dummy display. (Useful if no real display is connected)', 117 | ); 118 | 119 | argParser.addOption( 120 | 'dummy-display-size', 121 | help: 122 | 'Simulate a dummy display with a specific size in physical pixels. (Useful if no real display is connected)', 123 | valueHelp: 'width x height', 124 | ); 125 | } 126 | 127 | (int, int)? get displaySize { 128 | final size = stringArg('display-size'); 129 | if (size == null) { 130 | return null; 131 | } 132 | 133 | final parts = size.split('x'); 134 | if (parts.length != 2) { 135 | usageException( 136 | 'Invalid --display-size: Expected two dimensions separated by "x".', 137 | ); 138 | } 139 | 140 | try { 141 | return (int.parse(parts[0].trim()), int.parse(parts[1].trim())); 142 | } on FormatException { 143 | usageException( 144 | 'Invalid --display-size: Expected both dimensions to be integers.', 145 | ); 146 | } 147 | } 148 | 149 | (int, int)? get dummyDisplaySize { 150 | final size = stringArg('dummy-display-size'); 151 | if (size == null) { 152 | return null; 153 | } 154 | 155 | final parts = size.split('x'); 156 | if (parts.length != 2) { 157 | usageException( 158 | 'Invalid --dummy-display-size: Expected two dimensions separated by "x".', 159 | ); 160 | } 161 | 162 | try { 163 | return (int.parse(parts[0].trim()), int.parse(parts[1].trim())); 164 | } on FormatException { 165 | usageException( 166 | 'Invalid --dummy-display-size: Expected both dimensions to be integers.', 167 | ); 168 | } 169 | } 170 | 171 | bool get useDummyDisplay { 172 | final dummyDisplay = boolArg('dummy-display'); 173 | final dummyDisplaySize = stringArg('dummy-display-size'); 174 | if (dummyDisplay || dummyDisplaySize != null) { 175 | return true; 176 | } 177 | 178 | return false; 179 | } 180 | 181 | double? get pixelRatio { 182 | final ratio = stringArg('pixel-ratio'); 183 | if (ratio == null) { 184 | return null; 185 | } 186 | 187 | try { 188 | return double.parse(ratio); 189 | } on FormatException { 190 | usageException( 191 | 'Invalid --pixel-ratio: Expected a floating point number.', 192 | ); 193 | } 194 | } 195 | 196 | String get sshRemote { 197 | switch (argResults!.rest) { 198 | case [String id]: 199 | return id; 200 | case [String _, ...]: 201 | throw UsageException( 202 | 'Too many non-option arguments specified: ${argResults!.rest.skip(1)}', 203 | usage, 204 | ); 205 | case []: 206 | throw UsageException('Expected device id as non-option arg.', usage); 207 | default: 208 | throw StateError( 209 | 'Unexpected non-option argument list: ${argResults!.rest}', 210 | ); 211 | } 212 | } 213 | 214 | String get sshHostname { 215 | final remote = sshRemote; 216 | return remote.contains('@') ? remote.split('@').last : remote; 217 | } 218 | 219 | String? get sshUser { 220 | final remote = sshRemote; 221 | return remote.contains('@') ? remote.split('@').first : null; 222 | } 223 | 224 | final _contextOverrides = {}; 225 | 226 | void addContextOverride(dynamic Function() fn) { 227 | _contextOverrides[T] = fn; 228 | } 229 | 230 | void usesCustomCache({bool verboseHelp = false}) { 231 | argParser.addOption( 232 | 'github-artifacts-repo', 233 | help: 'The GitHub repository that provides the engine artifacts. If no ' 234 | 'run-id is specified, the release of this repository with tag ' 235 | '"engine/" will be used to look for the engine artifacts.', 236 | valueHelp: 'owner/repo', 237 | hide: !verboseHelp, 238 | ); 239 | 240 | argParser.addOption( 241 | 'github-artifacts-runid', 242 | help: 'If this is specified, use the artifacts produced by this GitHub ' 243 | 'Actions workflow run ID to look for the engine artifacts.', 244 | valueHelp: 'runID', 245 | hide: !verboseHelp, 246 | ); 247 | 248 | argParser.addOption( 249 | 'github-artifacts-engine-version', 250 | help: 'If a run-id is specified to download engine artifacts from a ' 251 | 'GitHub Actions run, this specifies the version of the engine ' 252 | 'artifacts that were built in the run. Specifying this will make ' 253 | 'sure the flutter SDK tries to use the right engine version. ' 254 | 'If this is not specified, the engine version will not be checked.', 255 | valueHelp: 'commit-hash', 256 | hide: !verboseHelp, 257 | ); 258 | 259 | argParser.addOption( 260 | 'github-artifacts-auth-token', 261 | help: 'The GitHub personal access token to use for downloading engine ' 262 | 'artifacts from a private repository. This is required if the ' 263 | 'repository is private.', 264 | valueHelp: 'token', 265 | hide: !verboseHelp, 266 | ); 267 | 268 | addContextOverride( 269 | () => createCustomCache( 270 | fs: globals.fs, 271 | shutdownHooks: globals.shutdownHooks, 272 | logger: globals.logger, 273 | platform: globals.platform, 274 | os: globals.os as MoreOperatingSystemUtils, 275 | projectFactory: globals.projectFactory, 276 | processManager: globals.processManager, 277 | ), 278 | ); 279 | } 280 | 281 | void usesDeviceManager() { 282 | // The option is added to the arg parser as a global option in 283 | // FlutterpiToolCommandRunner. 284 | 285 | addContextOverride( 286 | () => FlutterpiToolDeviceManager( 287 | logger: globals.logger, 288 | platform: globals.platform, 289 | cache: globals.cache as FlutterpiCache, 290 | operatingSystemUtils: globals.os as MoreOperatingSystemUtils, 291 | sshUtils: SshUtils( 292 | processUtils: globals.processUtils, 293 | defaultRemote: '', 294 | ), 295 | flutterpiToolConfig: FlutterPiToolConfig( 296 | fs: globals.fs, 297 | logger: globals.logger, 298 | platform: globals.platform, 299 | ), 300 | deviceId: stringArg(FlutterGlobalOptions.kDeviceIdOption, global: true), 301 | ), 302 | ); 303 | } 304 | 305 | void usesEngineFlavorOption() { 306 | argParser.addFlag( 307 | 'debug', 308 | help: 'Build for debug mode.', 309 | negatable: false, 310 | ); 311 | 312 | argParser.addFlag( 313 | 'profile', 314 | help: 'Build for profile mode.', 315 | negatable: false, 316 | ); 317 | 318 | argParser.addFlag( 319 | 'release', 320 | help: 'Build for release mode.', 321 | negatable: false, 322 | ); 323 | 324 | argParser.addFlag( 325 | 'debug-unoptimized', 326 | help: 327 | 'Build for debug mode and use unoptimized engine. (For stepping through engine code)', 328 | negatable: false, 329 | ); 330 | } 331 | 332 | void usesDebugSymbolsOption() { 333 | argParser.addFlag( 334 | 'debug-symbols', 335 | help: 'Include debug symbols in the output.', 336 | negatable: false, 337 | ); 338 | } 339 | 340 | bool getIncludeDebugSymbols() { 341 | return boolArg('debug-symbols'); 342 | } 343 | 344 | EngineFlavor getEngineFlavor() { 345 | final debug = boolArg('debug'); 346 | final profile = boolArg('profile'); 347 | final release = boolArg('release'); 348 | final debugUnopt = boolArg('debug-unoptimized'); 349 | 350 | final flags = [debug, profile, release, debugUnopt]; 351 | if (flags.where((flag) => flag).length > 1) { 352 | throw UsageException( 353 | 'Only one of "--debug", "--profile", "--release", ' 354 | 'or "--debug-unoptimized" can be specified.', 355 | '', 356 | ); 357 | } 358 | 359 | if (debug) { 360 | return EngineFlavor.debug; 361 | } else if (profile) { 362 | return EngineFlavor.profile; 363 | } else if (release) { 364 | return EngineFlavor.release; 365 | } else if (debugUnopt) { 366 | return EngineFlavor.debugUnopt; 367 | } else { 368 | return EngineFlavor.debug; 369 | } 370 | } 371 | 372 | Future> getDeviceBasedTargetPlatforms() async { 373 | final devices = await globals.deviceManager!.getDevices( 374 | filter: DeviceDiscoveryFilter(excludeDisconnected: false), 375 | ); 376 | if (devices.isEmpty) { 377 | return {}; 378 | } 379 | 380 | final targetPlatforms = { 381 | for (final device in devices.whereType()) 382 | await device.flutterpiTargetPlatform, 383 | }; 384 | 385 | return targetPlatforms.expand((p) => [p, p.genericVariant]).toSet(); 386 | } 387 | 388 | Future populateCache({ 389 | FlutterpiHostPlatform? hostPlatform, 390 | Set? targetPlatforms, 391 | Set? flavors, 392 | Set? runtimeModes, 393 | bool? includeDebugSymbols, 394 | }) async { 395 | hostPlatform ??= 396 | switch ((globals.os as MoreOperatingSystemUtils).fpiHostPlatform) { 397 | FlutterpiHostPlatform.darwinARM64 => FlutterpiHostPlatform.darwinX64, 398 | FlutterpiHostPlatform.windowsARM64 => FlutterpiHostPlatform.windowsX64, 399 | FlutterpiHostPlatform other => other, 400 | }; 401 | 402 | targetPlatforms ??= await getDeviceBasedTargetPlatforms(); 403 | 404 | flavors ??= {getEngineFlavor()}; 405 | 406 | runtimeModes ??= {getEngineFlavor().buildMode}; 407 | 408 | includeDebugSymbols ??= getIncludeDebugSymbols(); 409 | 410 | await globals.flutterpiCache.updateAll( 411 | {DevelopmentArtifact.universal}, 412 | host: hostPlatform, 413 | flutterpiPlatforms: targetPlatforms, 414 | runtimeModes: runtimeModes, 415 | engineFlavors: flavors, 416 | includeDebugSymbols: includeDebugSymbols, 417 | ); 418 | } 419 | 420 | @override 421 | void addBuildModeFlags({ 422 | required bool verboseHelp, 423 | bool defaultToRelease = true, 424 | bool excludeDebug = false, 425 | bool excludeRelease = false, 426 | }) { 427 | throw UnsupportedError( 428 | 'This method is not supported in Flutterpi commands.', 429 | ); 430 | } 431 | 432 | @override 433 | BuildMode getBuildMode() { 434 | return getEngineFlavor().buildMode; 435 | } 436 | 437 | @override 438 | bool get usingCISystem => false; 439 | 440 | @override 441 | String? get debugLogsDirectoryPath => null; 442 | 443 | Future runWithContext(FutureOr Function() fn) async { 444 | return fltool.runInContext( 445 | fn, 446 | overrides: { 447 | TemplateRenderer: () => const MustacheTemplateRenderer(), 448 | FlutterpiCache: () => FlutterpiCache( 449 | hooks: globals.shutdownHooks, 450 | logger: globals.logger, 451 | fileSystem: globals.fs, 452 | platform: globals.platform, 453 | osUtils: globals.os as MoreOperatingSystemUtils, 454 | projectFactory: globals.projectFactory, 455 | processManager: globals.processManager, 456 | github: createGithub( 457 | httpClient: http.IOClient( 458 | globals.httpClientFactory?.call() ?? HttpClient(), 459 | ), 460 | ), 461 | ), 462 | Cache: () => globals.flutterpiCache, 463 | OperatingSystemUtils: () => MoreOperatingSystemUtils( 464 | fileSystem: globals.fs, 465 | logger: globals.logger, 466 | platform: globals.platform, 467 | processManager: globals.processManager, 468 | ), 469 | Logger: createLogger, 470 | Artifacts: () => CachedArtifacts( 471 | fileSystem: globals.fs, 472 | platform: globals.platform, 473 | cache: globals.cache, 474 | operatingSystemUtils: globals.os, 475 | ), 476 | Usage: () => DisabledUsage(), 477 | FlutterPiToolConfig: () => FlutterPiToolConfig( 478 | fs: globals.fs, 479 | logger: globals.logger, 480 | platform: globals.platform, 481 | ), 482 | BuildTargets: () => const BuildTargetsImpl(), 483 | ApplicationPackageFactory: () => FlutterpiApplicationPackageFactory(), 484 | ..._contextOverrides, 485 | }, 486 | ); 487 | } 488 | 489 | @override 490 | Future runCommand(); 491 | 492 | @override 493 | Future run() async { 494 | Cache.flutterRoot = await getFlutterRoot(); 495 | 496 | return await runWithContext(() async { 497 | try { 498 | final result = await verifyThenRunCommand(null); 499 | 500 | await exitWithHooks( 501 | result.exitStatus == ExitStatus.success ? 0 : 1, 502 | shutdownHooks: globals.shutdownHooks, 503 | logger: globals.logger, 504 | ); 505 | } on ToolExit catch (e) { 506 | if (e.message != null) { 507 | globals.printError(e.message!); 508 | } 509 | 510 | await exitWithHooks( 511 | e.exitCode ?? 1, 512 | shutdownHooks: globals.shutdownHooks, 513 | logger: globals.logger, 514 | ); 515 | } on UsageException catch (e) { 516 | globals.printError(e.message); 517 | globals.printStatus(e.usage); 518 | 519 | await exitWithHooks( 520 | 1, 521 | shutdownHooks: globals.shutdownHooks, 522 | logger: globals.logger, 523 | ); 524 | } 525 | }); 526 | } 527 | } 528 | -------------------------------------------------------------------------------- /lib/src/common.dart: -------------------------------------------------------------------------------- 1 | // ignore_for_file: implementation_imports 2 | 3 | import 'package:flutterpi_tool/src/fltool/common.dart' show BuildMode; 4 | 5 | enum Bitness { b32, b64 } 6 | 7 | enum FlutterpiHostPlatform { 8 | darwinX64.b64('darwin-x64', 'macOS-X64', darwin: true), 9 | darwinARM64.b64('darwin-arm64', 'macOS-ARM64', darwin: true), 10 | linuxX64.b64('linux-x64', 'Linux-X64', linux: true), 11 | linuxARM.b32('linux-arm', 'Linux-ARM', linux: true), 12 | linuxARM64.b64('linux-arm64', 'Linux-ARM64', linux: true), 13 | linuxRV64.b64('linux-riscv64', 'Linux-RISCV64', linux: true), 14 | windowsX64.b64('windows-x64', 'Windows-X64', windows: true), 15 | windowsARM64.b64('windows-arm64', 'Windows-ARM64', windows: true); 16 | 17 | const FlutterpiHostPlatform.b32( 18 | this.name, 19 | this.githubName, { 20 | bool darwin = false, 21 | bool linux = false, 22 | bool windows = false, 23 | }) : bitness = Bitness.b32, 24 | isDarwin = darwin, 25 | isLinux = linux, 26 | isWindows = windows, 27 | isPosix = linux || darwin, 28 | assert( 29 | (darwin ? 1 : 0) + (linux ? 1 : 0) + (windows ? 1 : 0) == 1, 30 | 'Exactly one of darwin, linux, or windows must be specified.', 31 | ); 32 | 33 | const FlutterpiHostPlatform.b64( 34 | this.name, 35 | this.githubName, { 36 | bool darwin = false, 37 | bool linux = false, 38 | bool windows = false, 39 | }) : bitness = Bitness.b64, 40 | isDarwin = darwin, 41 | isLinux = linux, 42 | isWindows = windows, 43 | isPosix = linux || darwin, 44 | assert( 45 | (darwin ? 1 : 0) + (linux ? 1 : 0) + (windows ? 1 : 0) == 1, 46 | 'Exactly one of darwin, linux, or windows must be specified.', 47 | ); 48 | 49 | final String name; 50 | final String githubName; 51 | final Bitness bitness; 52 | final bool isDarwin; 53 | final bool isLinux; 54 | final bool isPosix; 55 | final bool isWindows; 56 | 57 | @override 58 | String toString() => name; 59 | } 60 | 61 | enum FlutterpiTargetPlatform { 62 | genericArmV7.generic32('armv7-generic', 'arm-linux-gnueabihf'), 63 | genericAArch64.generic64('aarch64-generic', 'aarch64-linux-gnu'), 64 | genericX64.generic64('x64-generic', 'x86_64-linux-gnu'), 65 | genericRiscv64.generic64('riscv64-generic', 'riscv64-linux-gnu'), 66 | pi3.tuned32('pi3', 'armv7-generic', 'arm-linux-gnueabihf'), 67 | pi3_64.tuned64('pi3-64', 'aarch64-generic', 'aarch64-linux-gnu'), 68 | pi4.tuned32('pi4', 'armv7-generic', 'arm-linux-gnueabihf'), 69 | pi4_64.tuned64('pi4-64', 'aarch64-generic', 'aarch64-linux-gnu'); 70 | 71 | const FlutterpiTargetPlatform.generic64(this.shortName, this.triple) 72 | : isGeneric = true, 73 | _genericVariantStr = null, 74 | bitness = Bitness.b64; 75 | 76 | const FlutterpiTargetPlatform.generic32(this.shortName, this.triple) 77 | : isGeneric = true, 78 | _genericVariantStr = null, 79 | bitness = Bitness.b32; 80 | 81 | const FlutterpiTargetPlatform.tuned32( 82 | this.shortName, 83 | this._genericVariantStr, 84 | this.triple, 85 | ) : isGeneric = false, 86 | bitness = Bitness.b32; 87 | 88 | const FlutterpiTargetPlatform.tuned64( 89 | this.shortName, 90 | this._genericVariantStr, 91 | this.triple, 92 | ) : isGeneric = false, 93 | bitness = Bitness.b64; 94 | 95 | final Bitness bitness; 96 | final String shortName; 97 | final bool isGeneric; 98 | final String? _genericVariantStr; 99 | final String triple; 100 | 101 | FlutterpiTargetPlatform get genericVariant { 102 | if (_genericVariantStr != null) { 103 | return values 104 | .singleWhere((target) => target.shortName == _genericVariantStr); 105 | } else { 106 | return this; 107 | } 108 | } 109 | 110 | @override 111 | String toString() { 112 | return shortName; 113 | } 114 | } 115 | 116 | enum EngineFlavor { 117 | debugUnopt._internal('debug_unopt', BuildMode.debug, unoptimized: true), 118 | debug._internal('debug', BuildMode.debug), 119 | profile._internal('profile', BuildMode.profile), 120 | release._internal('release', BuildMode.release); 121 | 122 | const EngineFlavor._internal( 123 | this.name, 124 | this.buildMode, { 125 | this.unoptimized = false, 126 | }); 127 | 128 | factory EngineFlavor(BuildMode mode, bool unoptimized) { 129 | return switch ((mode, unoptimized)) { 130 | (BuildMode.debug, true) => debugUnopt, 131 | (BuildMode.debug, false) => debug, 132 | (BuildMode.profile, false) => profile, 133 | (BuildMode.release, false) => release, 134 | (_, true) => throw ArgumentError.value( 135 | unoptimized, 136 | 'unoptimized', 137 | 'Unoptimized builds are only supported for debug engine.', 138 | ), 139 | _ => throw ArgumentError.value(mode, 'mode', 'Illegal build mode'), 140 | }; 141 | } 142 | 143 | final String name; 144 | 145 | final BuildMode buildMode; 146 | final bool unoptimized; 147 | 148 | @override 149 | String toString() => name; 150 | } 151 | -------------------------------------------------------------------------------- /lib/src/config.dart: -------------------------------------------------------------------------------- 1 | import 'package:file/file.dart'; 2 | import 'package:flutterpi_tool/src/fltool/common.dart'; 3 | 4 | class DeviceConfigEntry { 5 | const DeviceConfigEntry({ 6 | required this.id, 7 | required this.sshExecutable, 8 | required this.sshRemote, 9 | required this.remoteInstallPath, 10 | this.displaySizeMillimeters, 11 | this.devicePixelRatio, 12 | this.useDummyDisplay, 13 | this.dummyDisplaySize, 14 | }); 15 | 16 | final String id; 17 | final String? sshExecutable; 18 | final String sshRemote; 19 | final String? remoteInstallPath; 20 | final (int, int)? displaySizeMillimeters; 21 | final double? devicePixelRatio; 22 | final bool? useDummyDisplay; 23 | final (int, int)? dummyDisplaySize; 24 | 25 | static DeviceConfigEntry fromMap(Map map) { 26 | return DeviceConfigEntry( 27 | id: map['id'] as String, 28 | sshExecutable: map['sshExecutable'] as String?, 29 | sshRemote: map['sshRemote'] as String, 30 | remoteInstallPath: map['remoteInstallPath'] as String?, 31 | displaySizeMillimeters: switch (map['displaySizeMillimeters']) { 32 | [num width, num height] => (width.round(), height.round()), 33 | _ => null, 34 | }, 35 | devicePixelRatio: (map['devicePixelRatio'] as num?)?.toDouble(), 36 | useDummyDisplay: map['useDummyDisplay'] as bool?, 37 | dummyDisplaySize: switch (map['dummyDisplaySize']) { 38 | [num width, num height] => (width.round(), height.round()), 39 | _ => null, 40 | }, 41 | ); 42 | } 43 | 44 | Map toMap() { 45 | return { 46 | 'id': id, 47 | 'sshExecutable': sshExecutable, 48 | 'sshRemote': sshRemote, 49 | 'remoteInstallPath': remoteInstallPath, 50 | if (displaySizeMillimeters case (final width, final height)) 51 | 'displaySizeMillimeters': [width, height], 52 | if (devicePixelRatio case int devicePixelRatio) 53 | 'devicePixelRatio': devicePixelRatio, 54 | if (useDummyDisplay case bool useDummyDisplay) 55 | 'useDummyDisplay': useDummyDisplay, 56 | if (dummyDisplaySize case (final width, final height)) 57 | 'dummyDisplaySize': [width, height], 58 | }; 59 | } 60 | 61 | @override 62 | bool operator ==(Object other) { 63 | if (other.runtimeType != DeviceConfigEntry) { 64 | return false; 65 | } 66 | 67 | final DeviceConfigEntry otherEntry = other as DeviceConfigEntry; 68 | 69 | return id == otherEntry.id && 70 | sshExecutable == otherEntry.sshExecutable && 71 | sshRemote == otherEntry.sshRemote && 72 | remoteInstallPath == otherEntry.remoteInstallPath && 73 | displaySizeMillimeters == otherEntry.displaySizeMillimeters && 74 | devicePixelRatio == otherEntry.devicePixelRatio && 75 | useDummyDisplay == otherEntry.useDummyDisplay && 76 | dummyDisplaySize == otherEntry.dummyDisplaySize; 77 | } 78 | 79 | @override 80 | int get hashCode => Object.hash( 81 | id, 82 | sshExecutable, 83 | sshRemote, 84 | remoteInstallPath, 85 | displaySizeMillimeters, 86 | devicePixelRatio, 87 | useDummyDisplay, 88 | dummyDisplaySize, 89 | ); 90 | } 91 | 92 | class FlutterPiToolConfig { 93 | FlutterPiToolConfig({ 94 | required this.fs, 95 | required this.logger, 96 | required this.platform, 97 | }) : _config = Config( 98 | 'flutterpi_tool_config', 99 | fileSystem: fs, 100 | logger: logger, 101 | platform: platform, 102 | ); 103 | 104 | final FileSystem fs; 105 | final Logger logger; 106 | final Platform platform; 107 | final Config _config; 108 | 109 | List getDevices() { 110 | final entries = _config.getValue('devices'); 111 | 112 | switch (entries) { 113 | case List entries: 114 | final devices = entries.whereType().map((entry) { 115 | return DeviceConfigEntry.fromMap(entry.cast()); 116 | }).toList(); 117 | 118 | return devices; 119 | default: 120 | return []; 121 | } 122 | } 123 | 124 | void _setDevices(List devices) { 125 | _config.setValue('devices', devices.map((e) => e.toMap()).toList()); 126 | } 127 | 128 | void addDevice(DeviceConfigEntry device) { 129 | _setDevices([...getDevices(), device]); 130 | } 131 | 132 | void removeDevice(String id) { 133 | final devices = getDevices(); 134 | 135 | devices.removeWhere((entry) => entry.id == id); 136 | 137 | _setDevices(devices); 138 | } 139 | 140 | bool containsDevice(String id) { 141 | return getDevices().any((element) => element.id == id); 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /lib/src/devices/device_manager.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutterpi_tool/src/cache.dart'; 2 | import 'package:flutterpi_tool/src/devices/flutterpi_ssh/device_discovery.dart'; 3 | import 'package:flutterpi_tool/src/fltool/common.dart'; 4 | import 'package:flutterpi_tool/src/config.dart'; 5 | import 'package:flutterpi_tool/src/more_os_utils.dart'; 6 | import 'package:flutterpi_tool/src/devices/flutterpi_ssh/ssh_utils.dart'; 7 | 8 | class FlutterpiToolDeviceManager extends DeviceManager { 9 | FlutterpiToolDeviceManager({ 10 | required super.logger, 11 | required Platform platform, 12 | required FlutterpiCache cache, 13 | required MoreOperatingSystemUtils operatingSystemUtils, 14 | required SshUtils sshUtils, 15 | required FlutterPiToolConfig flutterpiToolConfig, 16 | required String? deviceId, 17 | }) : deviceDiscoverers = [ 18 | FlutterpiSshDeviceDiscovery( 19 | sshUtils: sshUtils, 20 | logger: logger, 21 | config: flutterpiToolConfig, 22 | os: operatingSystemUtils, 23 | cache: cache, 24 | ), 25 | ], 26 | _deviceId = deviceId; 27 | 28 | @override 29 | final List deviceDiscoverers; 30 | 31 | final String? _deviceId; 32 | 33 | @override 34 | String? get specifiedDeviceId => _deviceId; 35 | 36 | @override 37 | set specifiedDeviceId(String? deviceId) { 38 | throw UnsupportedError( 39 | 'Attempted to set device ID on FlutterPiToolDeviceManager.', 40 | ); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /lib/src/devices/flutterpi_ssh/device_discovery.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutterpi_tool/src/cache.dart'; 2 | import 'package:flutterpi_tool/src/devices/flutterpi_ssh/device.dart'; 3 | import 'package:flutterpi_tool/src/fltool/common.dart'; 4 | import 'package:flutterpi_tool/src/config.dart'; 5 | import 'package:flutterpi_tool/src/more_os_utils.dart'; 6 | import 'package:flutterpi_tool/src/devices/flutterpi_ssh/ssh_utils.dart'; 7 | 8 | class FlutterpiSshDeviceDiscovery extends PollingDeviceDiscovery { 9 | FlutterpiSshDeviceDiscovery({ 10 | required this.sshUtils, 11 | required this.config, 12 | required this.logger, 13 | required this.os, 14 | required this.cache, 15 | }) : super('SSH Devices'); 16 | 17 | final SshUtils sshUtils; 18 | final FlutterPiToolConfig config; 19 | final Logger logger; 20 | final MoreOperatingSystemUtils os; 21 | final FlutterpiCache cache; 22 | 23 | @override 24 | bool get canListAnything => true; 25 | 26 | Future getDeviceIfReachable({ 27 | Duration? timeout, 28 | required DeviceConfigEntry configEntry, 29 | }) async { 30 | final sshUtils = SshUtils( 31 | processUtils: this.sshUtils.processUtils, 32 | defaultRemote: configEntry.sshRemote, 33 | sshExecutable: configEntry.sshExecutable ?? this.sshUtils.sshExecutable, 34 | ); 35 | 36 | if (!await sshUtils.tryConnect(timeout: timeout)) { 37 | return null; 38 | } 39 | 40 | return FlutterpiSshDevice( 41 | id: configEntry.id, 42 | name: configEntry.id, 43 | sshUtils: sshUtils, 44 | remoteInstallPath: configEntry.remoteInstallPath, 45 | logger: logger, 46 | cache: cache, 47 | os: os, 48 | args: FlutterpiArgs( 49 | explicitDisplaySizeMillimeters: configEntry.displaySizeMillimeters, 50 | useDummyDisplay: configEntry.useDummyDisplay ?? false, 51 | dummyDisplaySize: configEntry.dummyDisplaySize, 52 | ), 53 | ); 54 | } 55 | 56 | @override 57 | Future> pollingGetDevices({Duration? timeout}) async { 58 | timeout ??= Duration(seconds: 5); 59 | 60 | final entries = config.getDevices(); 61 | 62 | final devices = await Future.wait([ 63 | for (final entry in entries) 64 | getDeviceIfReachable(configEntry: entry, timeout: timeout), 65 | ]); 66 | 67 | devices.removeWhere((element) => element == null); 68 | 69 | return List.from(devices); 70 | } 71 | 72 | @override 73 | bool get supportsPlatform => true; 74 | 75 | @override 76 | List get wellKnownIds => const []; 77 | } 78 | -------------------------------------------------------------------------------- /lib/src/devices/flutterpi_ssh/ssh_utils.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | import 'dart:io'; 3 | import 'package:flutterpi_tool/src/fltool/common.dart'; 4 | 5 | class SshException implements Exception { 6 | SshException(this.message); 7 | 8 | final String message; 9 | 10 | @override 11 | String toString() => message; 12 | } 13 | 14 | class SshUtils { 15 | SshUtils({ 16 | required this.processUtils, 17 | this.sshExecutable = 'ssh', 18 | this.scpExecutable = 'scp', 19 | required this.defaultRemote, 20 | }); 21 | 22 | final String sshExecutable; 23 | final String scpExecutable; 24 | final String defaultRemote; 25 | final ProcessUtils processUtils; 26 | 27 | List buildSshCommand({ 28 | bool? interactive = false, 29 | bool? allocateTTY, 30 | bool? exitOnForwardFailure, 31 | Iterable<(int, int)> remotePortForwards = const [], 32 | Iterable<(int, int)> localPortForwards = const [], 33 | Iterable extraArgs = const [], 34 | String? remote, 35 | String? command, 36 | }) { 37 | remote ??= defaultRemote; 38 | 39 | return [ 40 | sshExecutable, 41 | if (interactive != null) ...[ 42 | '-o', 43 | 'BatchMode=${interactive ? 'no' : 'yes'}', 44 | ], 45 | if (allocateTTY == true) '-tt', 46 | if (exitOnForwardFailure == true) ...[ 47 | '-o', 48 | 'ExitOnForwardFailure=yes', 49 | ] else if (exitOnForwardFailure == false) ...[ 50 | '-o', 51 | 'ExitOnForwardFailure=no', 52 | ], 53 | for (final (local, remote) in localPortForwards) ...[ 54 | '-L', 55 | '$local:localhost:$remote', 56 | ], 57 | for (final (remote, local) in remotePortForwards) ...[ 58 | '-R', 59 | '$local:localhost:$remote', 60 | ], 61 | if (command == null) '-T', 62 | ...extraArgs, 63 | remote, 64 | if (command != null) command, 65 | ]; 66 | } 67 | 68 | List buildUsermodAddGroupsCommand(Iterable groups) { 69 | if (groups.isEmpty) { 70 | throw ArgumentError.value(groups, 'groups', 'Groups must not be empty.'); 71 | } 72 | 73 | return ['usermod', '-aG', groups.join(','), r'$USER']; 74 | } 75 | 76 | Future runSsh({ 77 | String? remote, 78 | String? command, 79 | Iterable extraArgs = const [], 80 | bool throwOnError = false, 81 | String? workingDirectory, 82 | Map? environment, 83 | Duration? timeout, 84 | int timeoutRetries = 0, 85 | bool? allocateTTY, 86 | Iterable<(int, int)> localPortForwards = const [], 87 | Iterable<(int, int)> remotePortForwards = const [], 88 | bool? exitOnForwardFailure, 89 | }) { 90 | remote ??= defaultRemote; 91 | 92 | final cmd = buildSshCommand( 93 | allocateTTY: allocateTTY, 94 | exitOnForwardFailure: exitOnForwardFailure, 95 | localPortForwards: localPortForwards, 96 | extraArgs: extraArgs, 97 | remote: remote, 98 | command: command, 99 | ); 100 | 101 | try { 102 | return processUtils.run( 103 | cmd, 104 | throwOnError: throwOnError, 105 | workingDirectory: workingDirectory, 106 | environment: environment, 107 | timeout: timeout, 108 | timeoutRetries: timeoutRetries, 109 | ); 110 | } on ProcessException catch (e) { 111 | switch (e.errorCode) { 112 | case 255: 113 | throw SshException('SSH to "$remote" failed: $e'); 114 | default: 115 | throw SshException('Remote command failed: $e'); 116 | } 117 | } 118 | } 119 | 120 | Future startSsh({ 121 | String? remote, 122 | String? command, 123 | Iterable extraArgs = const [], 124 | String? workingDirectory, 125 | Map? environment, 126 | bool? allocateTTY, 127 | Iterable<(int, int)> remotePortForwards = const [], 128 | Iterable<(int, int)> localPortForwards = const [], 129 | bool? exitOnForwardFailure, 130 | ProcessStartMode mode = ProcessStartMode.normal, 131 | }) { 132 | remote ??= defaultRemote; 133 | 134 | final cmd = buildSshCommand( 135 | allocateTTY: allocateTTY, 136 | exitOnForwardFailure: exitOnForwardFailure, 137 | localPortForwards: localPortForwards, 138 | extraArgs: extraArgs, 139 | remote: remote, 140 | command: command, 141 | ); 142 | 143 | try { 144 | return processUtils.start( 145 | cmd, 146 | workingDirectory: workingDirectory, 147 | environment: environment, 148 | mode: mode, 149 | ); 150 | } on ProcessException catch (e) { 151 | switch (e.errorCode) { 152 | case 255: 153 | throw SshException('SSH to "$remote" failed: $e'); 154 | default: 155 | throw SshException('Remote command failed: $e'); 156 | } 157 | } 158 | } 159 | 160 | Future scp({ 161 | String? remote, 162 | required String localPath, 163 | required String remotePath, 164 | Iterable extraArgs = const [], 165 | bool throwOnError = false, 166 | String? workingDirectory, 167 | Map? environment, 168 | Duration? timeout, 169 | int timeoutRetries = 0, 170 | bool recursive = true, 171 | }) { 172 | remote ??= defaultRemote; 173 | 174 | try { 175 | return processUtils.run( 176 | [ 177 | scpExecutable, 178 | '-o', 179 | 'BatchMode=yes', 180 | if (recursive) '-r', 181 | ...extraArgs, 182 | localPath, 183 | '$remote:$remotePath', 184 | ], 185 | throwOnError: throwOnError, 186 | workingDirectory: workingDirectory, 187 | environment: environment, 188 | timeout: timeout, 189 | timeoutRetries: timeoutRetries, 190 | ); 191 | } on ProcessException catch (e) { 192 | switch (e.errorCode) { 193 | case 255: 194 | throw SshException('SSH to remote "$remote" failed: $e'); 195 | default: 196 | throw SshException('Remote command failed: $e'); 197 | } 198 | } 199 | } 200 | 201 | Future tryConnect({ 202 | Duration? timeout, 203 | bool throwOnError = false, 204 | }) async { 205 | final timeoutSecondsCeiled = switch (timeout) { 206 | Duration(inMicroseconds: final micros) => 207 | (micros + Duration.microsecondsPerSecond - 1) ~/ 208 | Duration.microsecondsPerSecond, 209 | _ => null, 210 | }; 211 | 212 | final result = await runSsh( 213 | command: null, 214 | extraArgs: [ 215 | if (timeoutSecondsCeiled != null) ...[ 216 | '-o', 217 | 'ConnectTimeout=$timeoutSecondsCeiled', 218 | ], 219 | ], 220 | throwOnError: throwOnError, 221 | ); 222 | 223 | if (result.exitCode == 0) { 224 | return true; 225 | } else { 226 | return false; 227 | } 228 | } 229 | 230 | Future copy({ 231 | required String localPath, 232 | required String remotePath, 233 | String? remote, 234 | }) { 235 | return scp( 236 | localPath: localPath, 237 | remotePath: remotePath, 238 | remote: remote, 239 | throwOnError: true, 240 | recursive: true, 241 | ); 242 | } 243 | 244 | Future uname({Iterable? args, Duration? timeout}) async { 245 | final command = ['uname', ...?args].join(' '); 246 | 247 | final result = await runSsh( 248 | command: command, 249 | throwOnError: true, 250 | timeout: timeout, 251 | ); 252 | 253 | return result.stdout.trim(); 254 | } 255 | 256 | Future id({Iterable? args, Duration? timeout}) async { 257 | final command = ['id', ...?args].join(' '); 258 | 259 | final result = await runSsh( 260 | command: command, 261 | throwOnError: true, 262 | timeout: timeout, 263 | ); 264 | 265 | return result.stdout.trim(); 266 | } 267 | 268 | Future makeExecutable({ 269 | Iterable? args, 270 | Duration? timeout, 271 | }) async { 272 | final command = ['chmod', '+x', ...?args].join(' '); 273 | 274 | await runSsh( 275 | command: command, 276 | throwOnError: true, 277 | timeout: timeout, 278 | ); 279 | } 280 | 281 | Future remoteUserBelongsToGroups(Iterable groups) async { 282 | final result = await id(args: ['-nG']); 283 | final userGroups = result.split(' '); 284 | return groups.every(userGroups.contains); 285 | } 286 | } 287 | -------------------------------------------------------------------------------- /lib/src/executable.dart: -------------------------------------------------------------------------------- 1 | // ignore_for_file: avoid_print, implementation_imports 2 | 3 | import 'dart:async'; 4 | import 'dart:io' as io; 5 | import 'package:args/command_runner.dart'; 6 | import 'package:flutterpi_tool/src/cli/commands/build.dart'; 7 | import 'package:flutterpi_tool/src/cli/command_runner.dart'; 8 | import 'package:flutterpi_tool/src/cli/commands/devices.dart'; 9 | import 'package:flutterpi_tool/src/cli/commands/precache.dart'; 10 | import 'package:flutterpi_tool/src/cli/commands/run.dart'; 11 | import 'package:flutterpi_tool/src/cli/commands/test.dart'; 12 | 13 | Future main(List args) async { 14 | final verbose = 15 | args.contains('-v') || args.contains('--verbose') || args.contains('-vv'); 16 | final powershellHelpIndex = args.indexOf('-?'); 17 | if (powershellHelpIndex != -1) { 18 | args[powershellHelpIndex] = '-h'; 19 | } 20 | 21 | final help = args.contains('-h') || 22 | args.contains('--help') || 23 | (args.isNotEmpty && args.first == 'help') || 24 | (args.length == 1 && verbose); 25 | final verboseHelp = help && verbose; 26 | 27 | final runner = FlutterpiToolCommandRunner(verboseHelp: verboseHelp); 28 | 29 | runner.addCommand(BuildCommand(verboseHelp: verboseHelp)); 30 | runner.addCommand(PrecacheCommand(verboseHelp: verboseHelp)); 31 | runner.addCommand(DevicesCommand(verboseHelp: verboseHelp)); 32 | runner.addCommand(RunCommand()); 33 | runner.addCommand(TestCommand()); 34 | 35 | runner.argParser 36 | ..addSeparator('Other options') 37 | ..addFlag('verbose', negatable: false, help: 'Enable verbose logging.'); 38 | 39 | try { 40 | await runner.run(args); 41 | } on UsageException catch (e) { 42 | print(e); 43 | io.exit(1); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /lib/src/fltool/common.dart: -------------------------------------------------------------------------------- 1 | export 'package:flutter_tools/executable.dart'; 2 | export 'package:flutter_tools/src/application_package.dart'; 3 | export 'package:flutter_tools/src/artifacts.dart'; 4 | export 'package:flutter_tools/src/base/common.dart'; 5 | export 'package:flutter_tools/src/base/config.dart'; 6 | export 'package:flutter_tools/src/base/error_handling_io.dart'; 7 | export 'package:flutter_tools/src/base/logger.dart'; 8 | export 'package:flutter_tools/src/base/os.dart'; 9 | export 'package:flutter_tools/src/base/platform.dart'; 10 | export 'package:flutter_tools/src/base/process.dart' hide exitWithHooks; 11 | export 'package:flutter_tools/src/base/template.dart'; 12 | export 'package:flutter_tools/src/build_info.dart'; 13 | export 'package:flutter_tools/src/build_system/build_system.dart'; 14 | export 'package:flutter_tools/src/build_system/depfile.dart'; 15 | export 'package:flutter_tools/src/build_system/targets/common.dart'; 16 | export 'package:flutter_tools/src/bundle.dart'; 17 | export 'package:flutter_tools/src/cache.dart'; 18 | export 'package:flutter_tools/src/device.dart'; 19 | export 'package:flutter_tools/src/device_port_forwarder.dart'; 20 | export 'package:flutter_tools/src/flutter_cache.dart'; 21 | export 'package:flutter_tools/src/isolated/mustache_template.dart'; 22 | export 'package:flutter_tools/src/project.dart'; 23 | export 'package:flutter_tools/src/reporting/reporting.dart'; 24 | export 'package:flutter_tools/src/runner/flutter_command.dart'; 25 | export 'package:flutter_tools/src/runner/flutter_command_runner.dart'; 26 | export 'package:flutter_tools/src/custom_devices/custom_device.dart'; 27 | export 'package:flutter_tools/src/protocol_discovery.dart'; 28 | export 'package:flutter_tools/src/build_system/build_targets.dart'; 29 | export 'package:flutter_tools/src/isolated/build_targets.dart'; 30 | export 'package:flutter_tools/src/flutter_application_package.dart'; 31 | export 'package:flutter_tools/src/base/terminal.dart'; 32 | export 'package:flutter_tools/src/commands/run.dart'; 33 | export 'package:flutter_tools/src/commands/test.dart'; 34 | export 'package:flutter_tools/src/commands/devices.dart' hide DevicesCommand; 35 | -------------------------------------------------------------------------------- /lib/src/fltool/context_runner.dart: -------------------------------------------------------------------------------- 1 | export 'package:flutter_tools/src/context_runner.dart'; 2 | -------------------------------------------------------------------------------- /lib/src/fltool/globals.dart: -------------------------------------------------------------------------------- 1 | export 'package:flutter_tools/src/globals.dart'; 2 | 3 | // ignore: implementation_imports 4 | import 'package:flutter_tools/src/base/context.dart' show context; 5 | import 'package:flutterpi_tool/src/cache.dart'; 6 | import 'package:flutterpi_tool/src/config.dart'; 7 | 8 | FlutterPiToolConfig get flutterPiToolConfig => 9 | context.get()!; 10 | FlutterpiCache get flutterpiCache => context.get()!; 11 | -------------------------------------------------------------------------------- /lib/src/github.dart: -------------------------------------------------------------------------------- 1 | // ignore_for_file: implementation_imports 2 | 3 | import 'dart:async'; 4 | import 'dart:io' as io; 5 | import 'package:github/github.dart' as gh; 6 | import 'package:http/http.dart' as http; 7 | import 'package:meta/meta.dart'; 8 | 9 | extension _AsyncPutIfAbsent on Map { 10 | Future asyncPutIfAbsent(K key, FutureOr Function() ifAbsent) async { 11 | if (containsKey(key)) { 12 | return this[key] as V; 13 | } 14 | 15 | return this[key] = await ifAbsent(); 16 | } 17 | } 18 | 19 | class GithubArtifact { 20 | GithubArtifact({ 21 | required this.name, 22 | required this.archiveDownloadUrl, 23 | }); 24 | 25 | final String name; 26 | final Uri archiveDownloadUrl; 27 | 28 | static GithubArtifact fromJson(Map json) { 29 | return GithubArtifact( 30 | name: json['name'], 31 | archiveDownloadUrl: Uri.parse(json['archive_download_url']), 32 | ); 33 | } 34 | } 35 | 36 | abstract class MyGithub { 37 | MyGithub.generative(); 38 | 39 | factory MyGithub({ 40 | http.Client? httpClient, 41 | gh.Authentication? auth, 42 | }) = MyGithubImpl; 43 | 44 | factory MyGithub.caching({ 45 | http.Client? httpClient, 46 | gh.Authentication? auth, 47 | }) { 48 | return CachingGithub( 49 | github: MyGithub(httpClient: httpClient, auth: auth), 50 | ); 51 | } 52 | 53 | factory MyGithub.wrapCaching({ 54 | required MyGithub github, 55 | }) = CachingGithub; 56 | 57 | Future getLatestRelease(gh.RepositorySlug repo); 58 | 59 | Future getReleaseByTagName( 60 | String tagName, { 61 | required gh.RepositorySlug repo, 62 | }); 63 | 64 | Future> getWorkflowRunArtifacts({ 65 | required gh.RepositorySlug repo, 66 | required int runId, 67 | String? nameFilter, 68 | }); 69 | 70 | Future getWorkflowRunArtifact( 71 | String name, { 72 | required gh.RepositorySlug repo, 73 | required int runId, 74 | }) async { 75 | final artifacts = await getWorkflowRunArtifacts( 76 | repo: repo, 77 | runId: runId, 78 | ); 79 | return artifacts.where((a) => a.name == name).firstOrNull; 80 | } 81 | 82 | void authenticate(io.HttpClientRequest request); 83 | 84 | gh.GitHub get github; 85 | 86 | gh.Authentication get auth; 87 | 88 | http.Client get client; 89 | } 90 | 91 | class MyGithubImpl extends MyGithub { 92 | MyGithubImpl({ 93 | http.Client? httpClient, 94 | gh.Authentication? auth, 95 | }) : github = gh.GitHub( 96 | client: httpClient ?? http.Client(), 97 | auth: auth ?? gh.Authentication.anonymous(), 98 | ), 99 | super.generative(); 100 | 101 | @override 102 | final gh.GitHub github; 103 | 104 | @override 105 | Future getLatestRelease(gh.RepositorySlug repo) async { 106 | return await github.repositories.getLatestRelease(repo); 107 | } 108 | 109 | @override 110 | Future getReleaseByTagName( 111 | String tagName, { 112 | required gh.RepositorySlug repo, 113 | }) async { 114 | return await github.repositories.getReleaseByTagName(repo, tagName); 115 | } 116 | 117 | @visibleForTesting 118 | String workflowRunArtifactsUrlPath(gh.RepositorySlug repo, int runId) { 119 | return '/repos/${repo.fullName}/actions/runs/$runId/artifacts'; 120 | } 121 | 122 | @override 123 | Future> getWorkflowRunArtifacts({ 124 | required gh.RepositorySlug repo, 125 | required int runId, 126 | String? nameFilter, 127 | }) async { 128 | final path = workflowRunArtifactsUrlPath(repo, runId); 129 | 130 | final results = []; 131 | 132 | int? total; 133 | var page = 1; 134 | var fetched = 0; 135 | do { 136 | final response = await github.getJSON( 137 | path, 138 | params: { 139 | if (nameFilter != null) 'name': nameFilter, 140 | 'page': page.toString(), 141 | 'per_page': '100', 142 | }, 143 | ); 144 | 145 | total ??= response['total_count'] as int; 146 | 147 | for (final artifact in response['artifacts']) { 148 | results.add(GithubArtifact.fromJson(artifact)); 149 | } 150 | 151 | fetched += (response['artifacts'] as Iterable).length; 152 | page++; 153 | } while (fetched < total); 154 | 155 | return results; 156 | } 157 | 158 | @override 159 | void authenticate(io.HttpClientRequest request) { 160 | if (github.auth.authorizationHeaderValue() case String header) { 161 | request.headers.add('Authorization', header); 162 | } 163 | } 164 | 165 | @override 166 | gh.Authentication get auth => github.auth; 167 | 168 | @override 169 | http.Client get client => github.client; 170 | } 171 | 172 | class CachingGithub extends MyGithub { 173 | CachingGithub({ 174 | required MyGithub github, 175 | }) : myGithub = github, 176 | super.generative(); 177 | 178 | final MyGithub myGithub; 179 | 180 | final _latestReleaseCache = {}; 181 | final _releaseByTagNameCache = {}; 182 | final _workflowRunArtifactsCache = >{}; 183 | 184 | @override 185 | Future getLatestRelease(gh.RepositorySlug repo) async { 186 | return await _latestReleaseCache.asyncPutIfAbsent( 187 | repo, 188 | () => myGithub.getLatestRelease(repo), 189 | ); 190 | } 191 | 192 | @override 193 | Future getReleaseByTagName( 194 | String tagName, { 195 | required gh.RepositorySlug repo, 196 | }) async { 197 | return await _releaseByTagNameCache.asyncPutIfAbsent( 198 | tagName, 199 | () => myGithub.getReleaseByTagName(tagName, repo: repo), 200 | ); 201 | } 202 | 203 | @override 204 | Future> getWorkflowRunArtifacts({ 205 | required gh.RepositorySlug repo, 206 | required int runId, 207 | String? nameFilter, 208 | }) async { 209 | final key = '${repo.fullName}/$runId'; 210 | return _workflowRunArtifactsCache.asyncPutIfAbsent( 211 | key, 212 | () => myGithub.getWorkflowRunArtifacts( 213 | repo: repo, 214 | runId: runId, 215 | nameFilter: nameFilter, 216 | ), 217 | ); 218 | } 219 | 220 | @override 221 | void authenticate(io.HttpClientRequest request) { 222 | myGithub.authenticate(request); 223 | } 224 | 225 | @override 226 | gh.GitHub get github => myGithub.github; 227 | 228 | @override 229 | gh.Authentication get auth => myGithub.auth; 230 | 231 | @override 232 | http.Client get client => myGithub.client; 233 | } 234 | -------------------------------------------------------------------------------- /lib/src/more_os_utils.dart: -------------------------------------------------------------------------------- 1 | import 'package:file/file.dart'; 2 | import 'package:flutterpi_tool/src/archive.dart'; 3 | import 'package:meta/meta.dart'; 4 | import 'package:process/process.dart'; 5 | 6 | import 'package:flutterpi_tool/src/fltool/common.dart'; 7 | import 'package:flutterpi_tool/src/common.dart'; 8 | 9 | enum ArchiveType { 10 | tarXz, 11 | tarGz, 12 | tar, 13 | zip, 14 | } 15 | 16 | abstract class MoreOperatingSystemUtils implements OperatingSystemUtils { 17 | factory MoreOperatingSystemUtils({ 18 | required FileSystem fileSystem, 19 | required Logger logger, 20 | required Platform platform, 21 | required ProcessManager processManager, 22 | }) { 23 | final os = OperatingSystemUtils( 24 | fileSystem: fileSystem, 25 | logger: logger, 26 | platform: platform, 27 | processManager: processManager, 28 | ); 29 | 30 | var moreOs = MoreOperatingSystemUtils.wrap(os); 31 | 32 | final processUtils = ProcessUtils( 33 | processManager: processManager, 34 | logger: logger, 35 | ); 36 | 37 | if (platform.isMacOS) { 38 | moreOs = MacosMoreOsUtils( 39 | delegate: moreOs, 40 | processUtils: processUtils, 41 | ); 42 | } else if (platform.isLinux) { 43 | moreOs = LinuxMoreOsUtils( 44 | delegate: moreOs, 45 | processUtils: processUtils, 46 | logger: logger, 47 | ); 48 | } 49 | 50 | return moreOs; 51 | } 52 | 53 | factory MoreOperatingSystemUtils.wrap(OperatingSystemUtils os) => 54 | MoreOperatingSystemUtilsWrapper(os: os); 55 | 56 | FlutterpiHostPlatform get fpiHostPlatform; 57 | 58 | @override 59 | void unpack( 60 | File gzippedTarFile, 61 | Directory targetDirectory, { 62 | ArchiveType? type, 63 | Archive Function(File)? decoder, 64 | }); 65 | } 66 | 67 | class MoreOperatingSystemUtilsWrapper implements MoreOperatingSystemUtils { 68 | MoreOperatingSystemUtilsWrapper({ 69 | required this.os, 70 | }); 71 | 72 | final OperatingSystemUtils os; 73 | 74 | @override 75 | void chmod(FileSystemEntity entity, String mode) { 76 | return os.chmod(entity, mode); 77 | } 78 | 79 | @override 80 | HostPlatform get hostPlatform => os.hostPlatform; 81 | 82 | @override 83 | FlutterpiHostPlatform get fpiHostPlatform { 84 | return switch (hostPlatform) { 85 | HostPlatform.darwin_x64 => FlutterpiHostPlatform.darwinX64, 86 | HostPlatform.darwin_arm64 => FlutterpiHostPlatform.darwinARM64, 87 | HostPlatform.linux_x64 => FlutterpiHostPlatform.linuxX64, 88 | HostPlatform.linux_arm64 => FlutterpiHostPlatform.linuxARM64, 89 | HostPlatform.windows_x64 => FlutterpiHostPlatform.windowsX64, 90 | HostPlatform.windows_arm64 => FlutterpiHostPlatform.windowsARM64, 91 | }; 92 | } 93 | 94 | @override 95 | void makeExecutable(File file) { 96 | return os.makeExecutable(file); 97 | } 98 | 99 | @override 100 | File makePipe(String path) => os.makePipe(path); 101 | 102 | @override 103 | String get pathVarSeparator => os.pathVarSeparator; 104 | 105 | @override 106 | void unpack( 107 | File gzippedTarFile, 108 | Directory targetDirectory, { 109 | Archive Function(File)? decoder, 110 | ArchiveType? type, 111 | }) { 112 | if (decoder == null && type == null || type == ArchiveType.tarGz) { 113 | return os.unpack(gzippedTarFile, targetDirectory); 114 | } else { 115 | decoder ??= switch (type) { 116 | ArchiveType.tarXz => (file) => TarDecoder() 117 | .decodeBytes(XZDecoder().decodeBytes(file.readAsBytesSync())), 118 | ArchiveType.tarGz => (file) => TarDecoder() 119 | .decodeBytes(GZipDecoder().decodeBytes(file.readAsBytesSync())), 120 | ArchiveType.tar => (file) => 121 | TarDecoder().decodeBytes(file.readAsBytesSync()), 122 | ArchiveType.zip => (file) => 123 | ZipDecoder().decodeBytes(file.readAsBytesSync()), 124 | null => throw 'unreachable', 125 | }; 126 | 127 | final archive = decoder(gzippedTarFile); 128 | 129 | _unpackArchive(archive, targetDirectory); 130 | } 131 | } 132 | 133 | void _unpackArchive(Archive archive, Directory targetDirectory) { 134 | final fs = targetDirectory.fileSystem; 135 | 136 | for (final archiveFile in archive.files) { 137 | if (!archiveFile.isFile || archiveFile.name.endsWith('/')) { 138 | continue; 139 | } 140 | 141 | final destFile = fs.file( 142 | fs.path.canonicalize( 143 | fs.path.join( 144 | targetDirectory.path, 145 | archiveFile.name, 146 | ), 147 | ), 148 | ); 149 | 150 | // Validate that the destFile is within the targetDirectory we want to 151 | // extract to. 152 | // 153 | // See https://snyk.io/research/zip-slip-vulnerability for more context. 154 | final destinationFileCanonicalPath = fs.path.canonicalize(destFile.path); 155 | final targetDirectoryCanonicalPath = 156 | fs.path.canonicalize(targetDirectory.path); 157 | 158 | if (!destinationFileCanonicalPath 159 | .startsWith(targetDirectoryCanonicalPath)) { 160 | throw StateError( 161 | 'Tried to extract the file $destinationFileCanonicalPath outside of the ' 162 | 'target directory $targetDirectoryCanonicalPath', 163 | ); 164 | } 165 | 166 | if (!destFile.parent.existsSync()) { 167 | destFile.parent.createSync(recursive: true); 168 | } 169 | 170 | destFile.writeAsBytesSync(archiveFile.content as List); 171 | } 172 | } 173 | 174 | @override 175 | void unzip(File file, Directory targetDirectory) { 176 | return os.unzip(file, targetDirectory); 177 | } 178 | 179 | @override 180 | Future findFreePort({bool ipv6 = false}) { 181 | return os.findFreePort(ipv6: ipv6); 182 | } 183 | 184 | @override 185 | int? getDirectorySize(Directory directory) { 186 | return os.getDirectorySize(directory); 187 | } 188 | 189 | @override 190 | Stream> gzipLevel1Stream(Stream> stream) { 191 | return os.gzipLevel1Stream(stream); 192 | } 193 | 194 | @override 195 | String get name => os.name; 196 | 197 | @override 198 | File? which(String execName) { 199 | return os.which(execName); 200 | } 201 | 202 | @override 203 | List whichAll(String execName) { 204 | return os.whichAll(execName); 205 | } 206 | } 207 | 208 | class DelegatingMoreOsUtils implements MoreOperatingSystemUtils { 209 | DelegatingMoreOsUtils({ 210 | required this.delegate, 211 | }); 212 | 213 | @protected 214 | final MoreOperatingSystemUtils delegate; 215 | 216 | @override 217 | void chmod(FileSystemEntity entity, String mode) { 218 | return delegate.chmod(entity, mode); 219 | } 220 | 221 | @override 222 | Future findFreePort({bool ipv6 = false}) { 223 | return delegate.findFreePort(ipv6: ipv6); 224 | } 225 | 226 | @override 227 | Stream> gzipLevel1Stream(Stream> stream) { 228 | return delegate.gzipLevel1Stream(stream); 229 | } 230 | 231 | @override 232 | HostPlatform get hostPlatform => delegate.hostPlatform; 233 | 234 | @override 235 | FlutterpiHostPlatform get fpiHostPlatform => delegate.fpiHostPlatform; 236 | 237 | @override 238 | void makeExecutable(File file) => delegate.makeExecutable(file); 239 | 240 | @override 241 | File makePipe(String path) => delegate.makePipe(path); 242 | 243 | @override 244 | String get name => delegate.name; 245 | 246 | @override 247 | String get pathVarSeparator => delegate.pathVarSeparator; 248 | 249 | @override 250 | void unpack( 251 | File gzippedTarFile, 252 | Directory targetDirectory, { 253 | ArchiveType? type, 254 | Archive Function(File)? decoder, 255 | }) { 256 | return delegate.unpack( 257 | gzippedTarFile, 258 | targetDirectory, 259 | decoder: decoder, 260 | type: type, 261 | ); 262 | } 263 | 264 | @override 265 | void unzip(File file, Directory targetDirectory) { 266 | return delegate.unzip(file, targetDirectory); 267 | } 268 | 269 | @override 270 | File? which(String execName) { 271 | return delegate.which(execName); 272 | } 273 | 274 | @override 275 | List whichAll(String execName) { 276 | return delegate.whichAll(execName); 277 | } 278 | 279 | @override 280 | int? getDirectorySize(Directory directory) { 281 | return delegate.getDirectorySize(directory); 282 | } 283 | } 284 | 285 | class PosixMoreOsUtils extends DelegatingMoreOsUtils { 286 | PosixMoreOsUtils({ 287 | required super.delegate, 288 | required this.processUtils, 289 | }); 290 | 291 | @protected 292 | final ProcessUtils processUtils; 293 | 294 | @override 295 | void unpack( 296 | File gzippedTarFile, 297 | Directory targetDirectory, { 298 | ArchiveType? type, 299 | Archive Function(File)? decoder, 300 | }) { 301 | if (decoder != null) { 302 | return delegate.unpack( 303 | gzippedTarFile, 304 | targetDirectory, 305 | decoder: decoder, 306 | type: type, 307 | ); 308 | } 309 | 310 | switch (type) { 311 | case ArchiveType.tarGz: 312 | case ArchiveType.tarXz: 313 | case ArchiveType.tar: 314 | final formatArg = switch (type) { 315 | ArchiveType.tarGz => 'z', 316 | ArchiveType.tarXz => 'J', 317 | ArchiveType.tar => '', 318 | _ => throw 'unreachable', 319 | }; 320 | 321 | processUtils.runSync( 322 | [ 323 | 'tar', 324 | '-x${formatArg}f', 325 | gzippedTarFile.path, 326 | '-C', 327 | targetDirectory.path, 328 | ], 329 | throwOnError: true, 330 | ); 331 | break; 332 | 333 | case ArchiveType.zip: 334 | unzip(gzippedTarFile, targetDirectory); 335 | 336 | case null: 337 | super.unpack(gzippedTarFile, targetDirectory); 338 | } 339 | } 340 | } 341 | 342 | class LinuxMoreOsUtils extends PosixMoreOsUtils { 343 | LinuxMoreOsUtils({ 344 | required super.delegate, 345 | required super.processUtils, 346 | required this.logger, 347 | }); 348 | 349 | @protected 350 | final Logger logger; 351 | 352 | FlutterpiHostPlatform _findHostPlatform() { 353 | final result = processUtils.runSync(['uname', '-m']); 354 | // On x64 stdout is "uname -m: x86_64" 355 | // On arm64 stdout is "uname -m: aarch64, arm64_v8a" 356 | if (result.exitCode != 0) { 357 | logger.printError( 358 | 'Encountered an error trying to run "uname -m":\n' 359 | ' exit code: ${result.exitCode}\n' 360 | ' stdout: ${result.stdout.trimRight()}\n' 361 | ' stderr: ${result.stderr.trimRight()}\n' 362 | 'Assuming host platform is ${FlutterpiHostPlatform.linuxX64}.', 363 | ); 364 | return FlutterpiHostPlatform.linuxX64; 365 | } 366 | 367 | final machine = result.stdout.trim(); 368 | 369 | if (machine.endsWith('x86_64')) { 370 | return FlutterpiHostPlatform.linuxX64; 371 | } else if (machine == 'aarch64' || machine == 'arm64') { 372 | return FlutterpiHostPlatform.linuxARM64; 373 | } else if (machine == 'armv7l' || machine == 'arm') { 374 | return FlutterpiHostPlatform.linuxARM; 375 | } else if (machine == 'riscv64') { 376 | return FlutterpiHostPlatform.linuxRV64; 377 | } else { 378 | logger.printError( 379 | 'Unrecognized host platform: uname -m: $machine\n' 380 | 'Assuming host platform is ${FlutterpiHostPlatform.linuxX64}.', 381 | ); 382 | return FlutterpiHostPlatform.linuxX64; 383 | } 384 | } 385 | 386 | @override 387 | late final FlutterpiHostPlatform fpiHostPlatform = _findHostPlatform(); 388 | } 389 | 390 | class MacosMoreOsUtils extends PosixMoreOsUtils { 391 | MacosMoreOsUtils({ 392 | required super.delegate, 393 | required super.processUtils, 394 | }); 395 | } 396 | -------------------------------------------------------------------------------- /lib/src/shutdown_hooks.dart: -------------------------------------------------------------------------------- 1 | // ignore_for_file: avoid_print, implementation_imports 2 | 3 | import 'dart:async'; 4 | import 'dart:io' as io; 5 | import 'package:flutterpi_tool/src/fltool/common.dart'; 6 | 7 | Future exitWithHooks( 8 | int code, { 9 | required ShutdownHooks shutdownHooks, 10 | required Logger logger, 11 | }) async { 12 | // Run shutdown hooks before flushing logs 13 | await shutdownHooks.runShutdownHooks(logger); 14 | 15 | final completer = Completer(); 16 | 17 | // Give the task / timer queue one cycle through before we hard exit. 18 | Timer.run(() { 19 | try { 20 | logger.printTrace('exiting with code $code'); 21 | io.exit(code); 22 | } catch (error, stackTrace) { 23 | // ignore: avoid_catches_without_on_clauses 24 | completer.completeError(error, stackTrace); 25 | } 26 | }); 27 | 28 | return completer.future; 29 | } 30 | -------------------------------------------------------------------------------- /pubspec.yaml: -------------------------------------------------------------------------------- 1 | name: flutterpi_tool 2 | description: A tool to make development & distribution of flutter-pi apps easier. 3 | version: 0.7.3 4 | repository: https://github.com/ardera/flutterpi_tool 5 | 6 | environment: 7 | sdk: ^3.0.5 8 | flutter: ">=3.29.0" 9 | 10 | executables: 11 | # TODO(ardera): Maybe add an alias `flutterpi-tool` here? 12 | flutterpi_tool: flutterpi_tool 13 | 14 | # Add regular dependencies here. 15 | dependencies: 16 | flutter_tools: 17 | sdk: flutter 18 | package_config: ^2.1.0 19 | github: ^9.15.0 20 | file: ">=6.1.4 <8.0.0" 21 | path: ^1.8.3 22 | args: ^2.4.0 23 | http: ">=0.13.6 <2.0.0" 24 | meta: ^1.10.0 25 | process: ^5.0.0 26 | unified_analytics: ^7.0.0 27 | archive: ^3.3.2 28 | crypto: ^3.0.3 29 | 30 | dev_dependencies: 31 | lints: ^2.0.0 32 | test: ^1.21.0 33 | -------------------------------------------------------------------------------- /test/build_bundle_test.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import 'package:args/args.dart'; 4 | import 'package:file/file.dart'; 5 | import 'package:file/memory.dart'; 6 | import 'package:flutterpi_tool/src/cli/command_runner.dart'; 7 | 8 | import 'package:flutterpi_tool/src/fltool/common.dart'; 9 | import 'package:flutterpi_tool/src/fltool/context_runner.dart' 10 | as context_runner; 11 | import 'package:flutterpi_tool/src/cli/commands/build.dart'; 12 | import 'package:flutterpi_tool/src/common.dart'; 13 | import 'package:test/test.dart'; 14 | 15 | class MockCommandRunner extends FlutterpiToolCommandRunner {} 16 | 17 | class MockBuildCommand extends BuildCommand { 18 | FutureOr Function()? runFunction; 19 | 20 | @override 21 | ArgResults? argResults; 22 | 23 | @override 24 | ArgResults? globalResults; 25 | 26 | @override 27 | Future run() async { 28 | return await runFunction!.call(); 29 | } 30 | } 31 | 32 | Future testBuildCommand( 33 | Iterable args, { 34 | required FutureOr Function(BuildCommand command) test, 35 | Logger? logger, 36 | FileSystem? fileSystem, 37 | }) async { 38 | logger ??= BufferLogger.test(); 39 | fileSystem ??= MemoryFileSystem.test(); 40 | 41 | final buildCommand = MockBuildCommand()..runFunction = () async {}; 42 | buildCommand.argResults = buildCommand.argParser.parse(args); 43 | 44 | final commandRunner = MockCommandRunner()..addCommand(buildCommand); 45 | buildCommand.globalResults = commandRunner.parse([]); 46 | 47 | await context_runner.runInContext( 48 | () async { 49 | await test(buildCommand); 50 | }, 51 | overrides: { 52 | Logger: () => logger, 53 | FileSystem: () => fileSystem, 54 | }, 55 | ); 56 | } 57 | 58 | void main() { 59 | test('simple dart defines work', () async { 60 | late final BuildInfo info; 61 | await testBuildCommand( 62 | ['--dart-define=FOO=BAR', '--debug'], 63 | test: (command) async { 64 | info = await command.getBuildInfo(); 65 | }, 66 | ); 67 | 68 | expect(info.dartDefines, contains('FOO=BAR')); 69 | expect(info.mode, equals(BuildMode.debug)); 70 | }); 71 | 72 | test('dart define from file works', () async { 73 | final fs = MemoryFileSystem.test(); 74 | 75 | fs.file('config.json').writeAsStringSync(''' 76 | {"FOO": "BAR"} 77 | '''); 78 | 79 | late final BuildInfo info; 80 | await testBuildCommand( 81 | ['--dart-define-from-file=config.json', '--debug'], 82 | test: (command) async { 83 | info = await command.getBuildInfo(); 84 | }, 85 | fileSystem: fs, 86 | ); 87 | 88 | expect(info.dartDefines, contains('FOO=BAR')); 89 | expect(info.mode, equals(BuildMode.debug)); 90 | }); 91 | 92 | test('profile mode works', () async { 93 | await testBuildCommand( 94 | ['--profile'], 95 | test: (command) async { 96 | expect((await command.getBuildInfo()).mode, equals(BuildMode.profile)); 97 | expect(command.getBuildMode(), equals(BuildMode.profile)); 98 | expect(command.getEngineFlavor(), equals(EngineFlavor.profile)); 99 | expect(command.getIncludeDebugSymbols(), isFalse); 100 | }, 101 | ); 102 | }); 103 | 104 | test('release mode works', () async { 105 | await testBuildCommand( 106 | ['--release'], 107 | test: (command) async { 108 | expect((await command.getBuildInfo()).mode, equals(BuildMode.release)); 109 | expect(command.getBuildMode(), equals(BuildMode.release)); 110 | expect(command.getEngineFlavor(), equals(EngineFlavor.release)); 111 | expect(command.getIncludeDebugSymbols(), isFalse); 112 | }, 113 | ); 114 | }); 115 | 116 | test('debug_unopt mode works', () async { 117 | await testBuildCommand( 118 | ['--debug-unoptimized'], 119 | test: (command) async { 120 | expect((await command.getBuildInfo()).mode, equals(BuildMode.debug)); 121 | expect(command.getBuildMode(), equals(BuildMode.debug)); 122 | expect(command.getEngineFlavor(), equals(EngineFlavor.debugUnopt)); 123 | expect(command.getIncludeDebugSymbols(), isFalse); 124 | }, 125 | ); 126 | }); 127 | 128 | test('debug symbols works', () async { 129 | await testBuildCommand( 130 | ['--debug-symbols'], 131 | test: (command) async { 132 | expect(command.getIncludeDebugSymbols(), isTrue); 133 | }, 134 | ); 135 | }); 136 | 137 | test('tree-shake-icons works', () async { 138 | await testBuildCommand( 139 | ['--debug', '--tree-shake-icons'], 140 | test: (command) async { 141 | final info = await command.getBuildInfo(); 142 | expect(info.treeShakeIcons, isFalse); 143 | }, 144 | ); 145 | 146 | await testBuildCommand( 147 | ['--profile', '--tree-shake-icons'], 148 | test: (command) async { 149 | final info = await command.getBuildInfo(); 150 | expect(info.treeShakeIcons, isTrue); 151 | }, 152 | ); 153 | 154 | await testBuildCommand( 155 | ['--profile', '--no-tree-shake-icons'], 156 | test: (command) async { 157 | final info = await command.getBuildInfo(); 158 | expect(info.treeShakeIcons, isFalse); 159 | }, 160 | ); 161 | }); 162 | 163 | test('target path works', () async { 164 | await testBuildCommand( 165 | ['--target=lib/other_main.dart'], 166 | test: (command) async { 167 | expect(command.targetFile, 'lib/other_main.dart'); 168 | }, 169 | ); 170 | }); 171 | } 172 | -------------------------------------------------------------------------------- /test/fake_github.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io'; 2 | 3 | import 'package:flutterpi_tool/src/github.dart'; 4 | import 'package:github/github.dart' as gh; 5 | import 'package:http/http.dart' as http; 6 | 7 | class FakeGithub extends MyGithub { 8 | FakeGithub() : super.generative(); 9 | 10 | @override 11 | Future getLatestRelease(gh.RepositorySlug repo) { 12 | if (getLatestReleaseFn == null) { 13 | throw UnimplementedError(); 14 | } 15 | 16 | return getLatestReleaseFn!(repo); 17 | } 18 | 19 | Future Function(gh.RepositorySlug repo)? getLatestReleaseFn; 20 | 21 | @override 22 | Future getReleaseByTagName( 23 | String tagName, { 24 | required gh.RepositorySlug repo, 25 | }) { 26 | if (getReleaseByTagNameFn == null) { 27 | throw UnimplementedError(); 28 | } 29 | 30 | return getReleaseByTagNameFn!(tagName, repo: repo); 31 | } 32 | 33 | Future Function( 34 | String tagName, { 35 | required gh.RepositorySlug repo, 36 | })? getReleaseByTagNameFn; 37 | 38 | @override 39 | Future> getWorkflowRunArtifacts({ 40 | required gh.RepositorySlug repo, 41 | required int runId, 42 | String? nameFilter, 43 | }) { 44 | if (getWorkflowRunArtifactsFn == null) { 45 | throw UnimplementedError(); 46 | } 47 | 48 | return getWorkflowRunArtifactsFn!( 49 | repo: repo, 50 | runId: runId, 51 | nameFilter: nameFilter, 52 | ); 53 | } 54 | 55 | Future> Function({ 56 | required gh.RepositorySlug repo, 57 | required int runId, 58 | String? nameFilter, 59 | })? getWorkflowRunArtifactsFn; 60 | 61 | @override 62 | void authenticate(HttpClientRequest request) { 63 | if (authenticateFn == null) { 64 | throw UnimplementedError(); 65 | } 66 | 67 | authenticateFn!(request); 68 | } 69 | 70 | void Function(HttpClientRequest request)? authenticateFn; 71 | 72 | @override 73 | gh.Authentication auth = gh.Authentication.anonymous(); 74 | 75 | http.Client? clientFake; 76 | 77 | @override 78 | http.Client get client { 79 | if (clientFake != null) { 80 | return clientFake!; 81 | } 82 | 83 | throw UnimplementedError(); 84 | } 85 | 86 | @override 87 | gh.GitHub get github { 88 | if (githubFake != null) { 89 | return githubFake!; 90 | } 91 | 92 | throw UnimplementedError(); 93 | } 94 | 95 | gh.GitHub? githubFake; 96 | } 97 | -------------------------------------------------------------------------------- /test/github_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutterpi_tool/src/github.dart'; 2 | import 'package:github/github.dart'; 3 | import 'package:http/http.dart'; 4 | import 'package:http/testing.dart'; 5 | import 'package:test/test.dart'; 6 | 7 | import 'github_test_api_output.dart'; 8 | 9 | void main() { 10 | test('workflow artifacts querying', () async { 11 | final client = MockClient((request) async { 12 | final uri1 = 13 | 'https://api.github.com/repos/ardera/flutter-ci/actions/runs/10332084071/artifacts?page=1&per_page=100'; 14 | 15 | final uri2 = 16 | 'https://api.github.com/repos/ardera/flutter-ci/actions/runs/10332084071/artifacts?page=2&per_page=100'; 17 | 18 | expect( 19 | request.url.toString(), 20 | anyOf(equals(uri1), equals(uri2)), 21 | ); 22 | 23 | if (request.url.queryParameters['page'] == '1') { 24 | return Response( 25 | githubWorkflowRunArtifactsPage1, 26 | 200, 27 | headers: { 28 | 'Content-Type': 'application/json', 29 | }, 30 | ); 31 | } else { 32 | expect(request.url.queryParameters['page'], '2'); 33 | return Response( 34 | githubWorkflowRunArtifactsPage2, 35 | 200, 36 | headers: { 37 | 'Content-Type': 'application/json', 38 | }, 39 | ); 40 | } 41 | }); 42 | 43 | final github = MyGithub( 44 | httpClient: client, 45 | ); 46 | 47 | final artifact = await github.getWorkflowRunArtifact( 48 | 'universal', 49 | repo: RepositorySlug('ardera', 'flutter-ci'), 50 | runId: 10332084071, 51 | ); 52 | 53 | expect(artifact, isNotNull); 54 | artifact!; 55 | 56 | expect(artifact.name, 'universal'); 57 | 58 | expect(artifact.archiveDownloadUrl, isNotNull); 59 | expect( 60 | artifact.archiveDownloadUrl.toString(), 61 | 'https://api.github.com/repos/ardera/flutter-ci/actions/artifacts/1797913057/zip', 62 | ); 63 | }); 64 | } 65 | -------------------------------------------------------------------------------- /test/src/fake_process_manager.dart: -------------------------------------------------------------------------------- 1 | // Copyright 2014 The Flutter Authors. All rights reserved. 2 | // Use of this source code is governed by a BSD-style license that can be 3 | // found in the LICENSE file. 4 | 5 | import 'dart:async'; 6 | import 'dart:convert'; 7 | import 'dart:io' as io 8 | show 9 | Process, 10 | ProcessResult, 11 | ProcessSignal, 12 | ProcessStartMode, 13 | systemEncoding; 14 | 15 | import 'package:file/file.dart'; 16 | import 'package:meta/meta.dart'; 17 | import 'package:process/process.dart'; 18 | import 'package:test/test.dart'; 19 | 20 | export 'package:process/process.dart' show ProcessManager; 21 | 22 | typedef VoidCallback = void Function(); 23 | 24 | /// A command for [FakeProcessManager]. 25 | @immutable 26 | class FakeCommand { 27 | const FakeCommand({ 28 | required this.command, 29 | this.workingDirectory, 30 | this.environment, 31 | this.encoding, 32 | this.duration = Duration.zero, 33 | this.onRun, 34 | this.exitCode = 0, 35 | this.stdout = '', 36 | this.stderr = '', 37 | this.completer, 38 | this.stdin, 39 | this.exception, 40 | this.outputFollowsExit = false, 41 | this.processStartMode, 42 | }); 43 | 44 | /// The exact commands that must be matched for this [FakeCommand] to be 45 | /// considered correct. 46 | final List command; 47 | 48 | /// The exact working directory that must be matched for this [FakeCommand] to 49 | /// be considered correct. 50 | /// 51 | /// If this is null, the working directory is ignored. 52 | final String? workingDirectory; 53 | 54 | /// The environment that must be matched for this [FakeCommand] to be considered correct. 55 | /// 56 | /// If this is null, then the environment is ignored. 57 | /// 58 | /// Otherwise, each key in this environment must be present and must have a 59 | /// value that matches the one given here for the [FakeCommand] to match. 60 | final Map? environment; 61 | 62 | /// The stdout and stderr encoding that must be matched for this [FakeCommand] 63 | /// to be considered correct. 64 | /// 65 | /// If this is null, then the encodings are ignored. 66 | final Encoding? encoding; 67 | 68 | /// The time to allow to elapse before returning the [exitCode], if this command 69 | /// is "executed". 70 | /// 71 | /// If you set this to a non-zero time, you should use a [FakeAsync] zone, 72 | /// otherwise the test will be artificially slow. 73 | final Duration duration; 74 | 75 | /// A callback that is run after [duration] expires but before the [exitCode] 76 | /// (and output) are passed back. 77 | final VoidCallback? onRun; 78 | 79 | /// The process' exit code. 80 | /// 81 | /// To simulate a never-ending process, set [duration] to a value greater than 82 | /// 15 minutes (the timeout for our tests). 83 | /// 84 | /// To simulate a crash, subtract the crash signal number from 256. For example, 85 | /// SIGPIPE (-13) is 243. 86 | final int exitCode; 87 | 88 | /// The output to simulate on stdout. This will be encoded as UTF-8 and 89 | /// returned in one go. 90 | final String stdout; 91 | 92 | /// The output to simulate on stderr. This will be encoded as UTF-8 and 93 | /// returned in one go. 94 | final String stderr; 95 | 96 | /// If provided, allows the command completion to be blocked until the future 97 | /// resolves. 98 | final Completer? completer; 99 | 100 | /// An optional stdin sink that will be exposed through the resulting 101 | /// [FakeProcess]. 102 | final IOSink? stdin; 103 | 104 | /// If provided, this exception will be thrown when the fake command is run. 105 | final Object? exception; 106 | 107 | /// When true, stdout and stderr will only be emitted after the `exitCode` 108 | /// [Future] on [io.Process] completes. 109 | final bool outputFollowsExit; 110 | 111 | final io.ProcessStartMode? processStartMode; 112 | 113 | void _matches( 114 | List command, 115 | String? workingDirectory, 116 | Map? environment, 117 | Encoding? encoding, 118 | io.ProcessStartMode? mode, 119 | ) { 120 | final List matchers = 121 | this.command.map((Pattern x) => x is String ? x : matches(x)).toList(); 122 | expect(command, matchers); 123 | if (processStartMode != null) { 124 | expect(mode, processStartMode); 125 | } 126 | if (this.workingDirectory != null) { 127 | expect(workingDirectory, this.workingDirectory); 128 | } 129 | if (this.environment != null) { 130 | expect(environment, this.environment); 131 | } 132 | if (this.encoding != null) { 133 | expect(encoding, this.encoding); 134 | } 135 | } 136 | } 137 | 138 | /// A fake process for use with [FakeProcessManager]. 139 | /// 140 | /// The process delays exit until both [duration] (if specified) has elapsed 141 | /// and [completer] (if specified) has completed. 142 | /// 143 | /// When [outputFollowsExit] is specified, bytes are streamed to [stderr] and 144 | /// [stdout] after the process exits. 145 | @visibleForTesting 146 | class FakeProcess implements io.Process { 147 | FakeProcess({ 148 | int exitCode = 0, 149 | Duration duration = Duration.zero, 150 | this.pid = 1234, 151 | List stderr = const [], 152 | IOSink? stdin, 153 | List stdout = const [], 154 | Completer? completer, 155 | bool outputFollowsExit = false, 156 | }) : _exitCode = exitCode, 157 | exitCode = Future.delayed(duration).then((void value) { 158 | if (completer != null) { 159 | return completer.future.then((void _) => exitCode); 160 | } 161 | return exitCode; 162 | }), 163 | _stderr = stderr, 164 | stdin = stdin ?? IOSink(StreamController>().sink), 165 | _stdout = stdout, 166 | _completer = completer { 167 | if (_stderr.isEmpty) { 168 | this.stderr = const Stream>.empty(); 169 | } else if (outputFollowsExit) { 170 | // Wait for the process to exit before emitting stderr. 171 | this.stderr = Stream>.fromFuture( 172 | this.exitCode.then((_) { 173 | // Return a Future so stderr isn't immediately available to those who 174 | // await exitCode, but is available asynchronously later. 175 | return Future>(() => _stderr); 176 | }), 177 | ); 178 | } else { 179 | this.stderr = Stream>.value(_stderr); 180 | } 181 | 182 | if (_stdout.isEmpty) { 183 | this.stdout = const Stream>.empty(); 184 | } else if (outputFollowsExit) { 185 | // Wait for the process to exit before emitting stdout. 186 | this.stdout = Stream>.fromFuture( 187 | this.exitCode.then((_) { 188 | // Return a Future so stdout isn't immediately available to those who 189 | // await exitCode, but is available asynchronously later. 190 | return Future>(() => _stdout); 191 | }), 192 | ); 193 | } else { 194 | this.stdout = Stream>.value(_stdout); 195 | } 196 | } 197 | 198 | /// The process exit code. 199 | final int _exitCode; 200 | 201 | /// When specified, blocks process exit until completed. 202 | final Completer? _completer; 203 | 204 | @override 205 | final Future exitCode; 206 | 207 | @override 208 | final int pid; 209 | 210 | /// The raw byte content of stderr. 211 | final List _stderr; 212 | 213 | @override 214 | late final Stream> stderr; 215 | 216 | @override 217 | final IOSink stdin; 218 | 219 | @override 220 | late final Stream> stdout; 221 | 222 | /// The raw byte content of stdout. 223 | final List _stdout; 224 | 225 | /// The list of [kill] signals this process received so far. 226 | @visibleForTesting 227 | List get signals => _signals; 228 | final List _signals = []; 229 | 230 | @override 231 | bool kill([io.ProcessSignal signal = io.ProcessSignal.sigterm]) { 232 | _signals.add(signal); 233 | 234 | // Killing a fake process has no effect. 235 | return true; 236 | } 237 | } 238 | 239 | abstract class FakeProcessManager implements ProcessManager { 240 | /// A fake [ProcessManager] which responds to all commands as if they had run 241 | /// instantaneously with an exit code of 0 and no output. 242 | factory FakeProcessManager.any() = _FakeAnyProcessManager; 243 | 244 | /// A fake [ProcessManager] which responds to particular commands with 245 | /// particular results. 246 | /// 247 | /// On creation, pass in a list of [FakeCommand] objects. When the 248 | /// [ProcessManager] methods such as [start] are invoked, the next 249 | /// [FakeCommand] must match (otherwise the test fails); its settings are used 250 | /// to simulate the result of running that command. 251 | /// 252 | /// If no command is found, then one is implied which immediately returns exit 253 | /// code 0 with no output. 254 | /// 255 | /// There is no logic to ensure that all the listed commands are run. Use 256 | /// [FakeCommand.onRun] to set a flag, or specify a sentinel command as your 257 | /// last command and verify its execution is successful, to ensure that all 258 | /// the specified commands are actually called. 259 | factory FakeProcessManager.list(List commands) = 260 | _SequenceProcessManager; 261 | factory FakeProcessManager.empty() => 262 | _SequenceProcessManager([]); 263 | 264 | FakeProcessManager._(); 265 | 266 | /// Adds a new [FakeCommand] to the current process manager. 267 | /// 268 | /// This can be used to configure test expectations after the [ProcessManager] has been 269 | /// provided to another interface. 270 | /// 271 | /// This is a no-op on [FakeProcessManager.any]. 272 | void addCommand(FakeCommand command); 273 | 274 | /// Add multiple [FakeCommand] to the current process manager. 275 | void addCommands(Iterable commands) { 276 | commands.forEach(addCommand); 277 | } 278 | 279 | final Map _fakeRunningProcesses = {}; 280 | 281 | /// Whether this fake has more [FakeCommand]s that are expected to run. 282 | /// 283 | /// This is always `true` for [FakeProcessManager.any]. 284 | bool get hasRemainingExpectations; 285 | 286 | /// The expected [FakeCommand]s that have not yet run. 287 | List get _remainingExpectations; 288 | 289 | @protected 290 | FakeCommand findCommand( 291 | List command, 292 | String? workingDirectory, 293 | Map? environment, 294 | Encoding? encoding, 295 | io.ProcessStartMode? mode, 296 | ); 297 | 298 | int _pid = 9999; 299 | 300 | FakeProcess _runCommand( 301 | List command, { 302 | String? workingDirectory, 303 | Map? environment, 304 | Encoding? encoding, 305 | io.ProcessStartMode? mode, 306 | }) { 307 | _pid += 1; 308 | final FakeCommand fakeCommand = findCommand( 309 | command, 310 | workingDirectory, 311 | environment, 312 | encoding, 313 | mode, 314 | ); 315 | if (fakeCommand.exception != null) { 316 | assert( 317 | fakeCommand.exception is Exception || fakeCommand.exception is Error, 318 | ); 319 | throw fakeCommand.exception!; // ignore: only_throw_errors 320 | } 321 | if (fakeCommand.onRun != null) { 322 | fakeCommand.onRun!(); 323 | } 324 | return FakeProcess( 325 | duration: fakeCommand.duration, 326 | exitCode: fakeCommand.exitCode, 327 | pid: _pid, 328 | stderr: 329 | encoding?.encode(fakeCommand.stderr) ?? fakeCommand.stderr.codeUnits, 330 | stdin: fakeCommand.stdin, 331 | stdout: 332 | encoding?.encode(fakeCommand.stdout) ?? fakeCommand.stdout.codeUnits, 333 | completer: fakeCommand.completer, 334 | outputFollowsExit: fakeCommand.outputFollowsExit, 335 | ); 336 | } 337 | 338 | @override 339 | Future start( 340 | List command, { 341 | String? workingDirectory, 342 | Map? environment, 343 | bool includeParentEnvironment = true, // ignored 344 | bool runInShell = false, // ignored 345 | io.ProcessStartMode mode = io.ProcessStartMode.normal, 346 | }) { 347 | final FakeProcess process = _runCommand( 348 | command.cast(), 349 | workingDirectory: workingDirectory, 350 | environment: environment, 351 | encoding: io.systemEncoding, 352 | mode: mode, 353 | ); 354 | if (process._completer != null) { 355 | _fakeRunningProcesses[process.pid] = process; 356 | process.exitCode.whenComplete(() { 357 | _fakeRunningProcesses.remove(process.pid); 358 | }); 359 | } 360 | return Future.value(process); 361 | } 362 | 363 | @override 364 | Future run( 365 | List command, { 366 | String? workingDirectory, 367 | Map? environment, 368 | bool includeParentEnvironment = true, // ignored 369 | bool runInShell = false, // ignored 370 | Encoding? stdoutEncoding = io.systemEncoding, 371 | Encoding? stderrEncoding = io.systemEncoding, 372 | }) async { 373 | final FakeProcess process = _runCommand( 374 | command.cast(), 375 | workingDirectory: workingDirectory, 376 | environment: environment, 377 | encoding: stdoutEncoding, 378 | ); 379 | await process.exitCode; 380 | return io.ProcessResult( 381 | process.pid, 382 | process._exitCode, 383 | stdoutEncoding == null 384 | ? process._stdout 385 | : await stdoutEncoding.decodeStream(process.stdout), 386 | stderrEncoding == null 387 | ? process._stderr 388 | : await stderrEncoding.decodeStream(process.stderr), 389 | ); 390 | } 391 | 392 | @override 393 | io.ProcessResult runSync( 394 | List command, { 395 | String? workingDirectory, 396 | Map? environment, 397 | bool includeParentEnvironment = true, // ignored 398 | bool runInShell = false, // ignored 399 | Encoding? stdoutEncoding = io.systemEncoding, 400 | Encoding? stderrEncoding = io.systemEncoding, 401 | }) { 402 | final FakeProcess process = _runCommand( 403 | command.cast(), 404 | workingDirectory: workingDirectory, 405 | environment: environment, 406 | encoding: stdoutEncoding, 407 | ); 408 | return io.ProcessResult( 409 | process.pid, 410 | process._exitCode, 411 | stdoutEncoding == null 412 | ? process._stdout 413 | : stdoutEncoding.decode(process._stdout), 414 | stderrEncoding == null 415 | ? process._stderr 416 | : stderrEncoding.decode(process._stderr), 417 | ); 418 | } 419 | 420 | /// Returns false if executable in [excludedExecutables]. 421 | @override 422 | bool canRun(dynamic executable, {String? workingDirectory}) => 423 | !excludedExecutables.contains(executable); 424 | 425 | Set excludedExecutables = {}; 426 | 427 | @override 428 | bool killPid(int pid, [io.ProcessSignal signal = io.ProcessSignal.sigterm]) { 429 | // Killing a fake process has no effect unless it has an attached completer. 430 | final FakeProcess? fakeProcess = _fakeRunningProcesses[pid]; 431 | if (fakeProcess == null) { 432 | return false; 433 | } 434 | fakeProcess.kill(signal); 435 | if (fakeProcess._completer != null) { 436 | fakeProcess._completer!.complete(); 437 | } 438 | return true; 439 | } 440 | } 441 | 442 | class _FakeAnyProcessManager extends FakeProcessManager { 443 | _FakeAnyProcessManager() : super._(); 444 | 445 | @override 446 | FakeCommand findCommand( 447 | List command, 448 | String? workingDirectory, 449 | Map? environment, 450 | Encoding? encoding, 451 | io.ProcessStartMode? mode, 452 | ) { 453 | return FakeCommand( 454 | command: command, 455 | workingDirectory: workingDirectory, 456 | environment: environment, 457 | encoding: encoding, 458 | processStartMode: mode, 459 | ); 460 | } 461 | 462 | @override 463 | void addCommand(FakeCommand command) {} 464 | 465 | @override 466 | bool get hasRemainingExpectations => true; 467 | 468 | @override 469 | List get _remainingExpectations => []; 470 | } 471 | 472 | class _SequenceProcessManager extends FakeProcessManager { 473 | _SequenceProcessManager(this._commands) : super._(); 474 | 475 | final List _commands; 476 | 477 | @override 478 | FakeCommand findCommand( 479 | List command, 480 | String? workingDirectory, 481 | Map? environment, 482 | Encoding? encoding, 483 | io.ProcessStartMode? mode, 484 | ) { 485 | expect( 486 | _commands, 487 | isNotEmpty, 488 | reason: 489 | 'ProcessManager was told to execute $command (in $workingDirectory) ' 490 | 'but the FakeProcessManager.list expected no more processes.', 491 | ); 492 | _commands.first 493 | ._matches(command, workingDirectory, environment, encoding, mode); 494 | return _commands.removeAt(0); 495 | } 496 | 497 | @override 498 | void addCommand(FakeCommand command) { 499 | _commands.add(command); 500 | } 501 | 502 | @override 503 | bool get hasRemainingExpectations => _commands.isNotEmpty; 504 | 505 | @override 506 | List get _remainingExpectations => _commands; 507 | } 508 | 509 | /// Matcher that successfully matches against a [FakeProcessManager] with 510 | /// no remaining expectations ([item.hasRemainingExpectations] returns false). 511 | const Matcher hasNoRemainingExpectations = _HasNoRemainingExpectations(); 512 | 513 | class _HasNoRemainingExpectations extends Matcher { 514 | const _HasNoRemainingExpectations(); 515 | 516 | @override 517 | bool matches(dynamic item, Map matchState) => 518 | item is FakeProcessManager && !item.hasRemainingExpectations; 519 | 520 | @override 521 | Description describe(Description description) => 522 | description.add('a fake process manager with no remaining expectations'); 523 | 524 | @override 525 | Description describeMismatch( 526 | dynamic item, 527 | Description description, 528 | Map matchState, 529 | bool verbose, 530 | ) { 531 | final FakeProcessManager fakeProcessManager = item as FakeProcessManager; 532 | return description.add( 533 | 'has remaining expectations:\n${fakeProcessManager._remainingExpectations.map((FakeCommand command) => command.command).join('\n')}', 534 | ); 535 | } 536 | } 537 | --------------------------------------------------------------------------------