├── lib ├── flutterpi_tool.dart └── src │ ├── cli │ ├── commands │ │ ├── test.dart │ │ ├── precache.dart │ │ ├── run.dart │ │ └── build.dart │ ├── command_runner.dart │ └── flutterpi_command.dart │ ├── archive.dart │ ├── shutdown_hooks.dart │ ├── application_package_factory.dart │ ├── devices │ ├── device_manager.dart │ └── flutterpi_ssh │ │ ├── device_discovery.dart │ │ └── ssh_utils.dart │ ├── fltool │ ├── globals.dart │ └── common.dart │ ├── executable.dart │ ├── context.dart │ ├── archive │ ├── lzma │ │ └── range_decoder.dart │ └── xz_decoder.dart │ ├── build_system │ ├── extended_environment.dart │ └── build_app.dart │ ├── common.dart │ ├── config.dart │ ├── github.dart │ ├── artifacts.dart │ ├── authenticating_artifact_updater.dart │ └── more_os_utils.dart ├── .vscode └── settings.json ├── bin └── flutterpi_tool.dart ├── .devcontainer ├── Dockerfile └── devcontainer.json ├── pubspec.yaml ├── test ├── src │ ├── fake_doctor.dart │ ├── mock_build_system.dart │ ├── mock_flutterpi_artifacts.dart │ ├── fake_os_utils.dart │ ├── fake_device_manager.dart │ ├── test_feature_flags.dart │ ├── fake_flutter_version.dart │ ├── mock_app_builder.dart │ ├── mock_more_os_utils.dart │ ├── context.dart │ └── fake_device.dart ├── github_test.dart ├── fake_github.dart ├── commands │ └── run_test.dart └── config_test.dart ├── analysis_options.yaml ├── LICENSE ├── .gitignore ├── .dockerignore ├── .github └── workflows │ ├── flutter.yml │ └── build-app.yml ├── cliff.toml ├── README.md └── CHANGELOG.md /lib/flutterpi_tool.dart: -------------------------------------------------------------------------------- 1 | export 'src/executable.dart' show main; 2 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "dart.lineLength": 80, 3 | "editor.rulers": [ 4 | 80, 5 | ] 6 | } -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 fl; 3 | 4 | class TestCommand extends fl.TestCommand with FlutterpiCommandMixin { 5 | TestCommand(); 6 | } 7 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /pubspec.yaml: -------------------------------------------------------------------------------- 1 | name: flutterpi_tool 2 | description: A tool to make development & distribution of flutter-pi apps easier. 3 | version: 0.10.1 4 | repository: https://github.com/ardera/flutterpi_tool 5 | 6 | environment: 7 | sdk: ^3.0.5 8 | flutter: ">=3.38.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 <9.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/src/fake_doctor.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutterpi_tool/src/fltool/common.dart' as fl; 2 | import 'package:test/fake.dart'; 3 | 4 | class FakeAndroidLicenseValidator extends Fake 5 | implements fl.AndroidLicenseValidator { 6 | @override 7 | Future get licensesAccepted async => 8 | fl.LicensesAccepted.all; 9 | } 10 | 11 | class FakeDoctor extends fl.Doctor { 12 | FakeDoctor(fl.Logger logger, {super.clock = const fl.SystemClock()}) 13 | : super(logger: logger); 14 | 15 | @override 16 | bool canListAnything = true; 17 | 18 | @override 19 | bool canLaunchAnything = true; 20 | 21 | @override 22 | late List validators = 23 | super.validators.map((v) { 24 | if (v is fl.AndroidLicenseValidator) { 25 | return FakeAndroidLicenseValidator(); 26 | } 27 | return v; 28 | }).toList(); 29 | } 30 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /lib/src/application_package_factory.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutterpi_tool/src/devices/flutterpi_ssh/device.dart'; 2 | import 'package:flutterpi_tool/src/fltool/common.dart'; 3 | 4 | class FlutterpiApplicationPackageFactory 5 | implements FlutterApplicationPackageFactory { 6 | @override 7 | Future getPackageForPlatform( 8 | TargetPlatform platform, { 9 | BuildInfo? buildInfo, 10 | File? applicationBinary, 11 | }) async { 12 | switch (platform) { 13 | case TargetPlatform.linux_arm64: 14 | case TargetPlatform.linux_x64: 15 | final flutterProject = FlutterProject.current(); 16 | 17 | return BuildableFlutterpiAppBundle( 18 | id: flutterProject.manifest.appName, 19 | name: flutterProject.manifest.appName, 20 | displayName: flutterProject.manifest.appName, 21 | ); 22 | default: 23 | return null; 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /lib/src/devices/device_manager.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutterpi_tool/src/devices/flutterpi_ssh/device_discovery.dart'; 2 | import 'package:flutterpi_tool/src/fltool/common.dart'; 3 | import 'package:flutterpi_tool/src/config.dart'; 4 | import 'package:flutterpi_tool/src/more_os_utils.dart'; 5 | import 'package:flutterpi_tool/src/devices/flutterpi_ssh/ssh_utils.dart'; 6 | 7 | class FlutterpiToolDeviceManager extends DeviceManager { 8 | FlutterpiToolDeviceManager({ 9 | required super.logger, 10 | required Platform platform, 11 | required MoreOperatingSystemUtils operatingSystemUtils, 12 | required SshUtils sshUtils, 13 | required FlutterPiToolConfig flutterpiToolConfig, 14 | this.specifiedDeviceId, 15 | }) : deviceDiscoverers = [ 16 | FlutterpiSshDeviceDiscovery( 17 | sshUtils: sshUtils, 18 | logger: logger, 19 | config: flutterpiToolConfig, 20 | os: operatingSystemUtils, 21 | ), 22 | ]; 23 | @override 24 | final List deviceDiscoverers; 25 | 26 | @override 27 | String? specifiedDeviceId; 28 | } 29 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/artifacts.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/config.dart'; 9 | import 'package:flutterpi_tool/src/devices/flutterpi_ssh/ssh_utils.dart'; 10 | import 'package:flutterpi_tool/src/fltool/common.dart' as fl; 11 | import 'package:flutterpi_tool/src/more_os_utils.dart'; 12 | 13 | FlutterPiToolConfig get flutterPiToolConfig => 14 | context.get()!; 15 | FlutterpiCache get flutterpiCache => context.get()! as FlutterpiCache; 16 | 17 | FlutterpiArtifacts get flutterpiArtifacts => 18 | context.get()! as FlutterpiArtifacts; 19 | MoreOperatingSystemUtils get moreOs => 20 | context.get()! as MoreOperatingSystemUtils; 21 | 22 | SshUtils get sshUtils => context.get()!; 23 | 24 | AppBuilder get builder => context.get()!; 25 | -------------------------------------------------------------------------------- /.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 -------------------------------------------------------------------------------- /test/src/mock_build_system.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import 'package:test/test.dart'; 4 | 5 | import 'package:flutterpi_tool/src/fltool/common.dart' as fl; 6 | 7 | class MockBuildSystem implements fl.BuildSystem { 8 | Future Function( 9 | fl.Target target, 10 | fl.Environment environment, { 11 | fl.BuildSystemConfig buildSystemConfig, 12 | })? buildFn; 13 | 14 | @override 15 | Future build( 16 | fl.Target target, 17 | fl.Environment environment, { 18 | fl.BuildSystemConfig buildSystemConfig = const fl.BuildSystemConfig(), 19 | }) { 20 | if (buildFn == null) { 21 | fail('Expected buildFn to not be called.'); 22 | } 23 | 24 | return buildFn!(target, environment, buildSystemConfig: buildSystemConfig); 25 | } 26 | 27 | Future Function( 28 | fl.Target target, 29 | fl.Environment environment, 30 | fl.BuildResult? previousBuild, 31 | )? buildIncrementalFn; 32 | 33 | @override 34 | Future buildIncremental( 35 | fl.Target target, 36 | fl.Environment environment, 37 | fl.BuildResult? previousBuild, 38 | ) { 39 | if (buildIncrementalFn == null) { 40 | fail('Expected buildIncrementalFn to not be called.'); 41 | } 42 | 43 | return buildIncrementalFn!(target, environment, previousBuild); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /.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/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 | permissions: 15 | contents: read 16 | 17 | jobs: 18 | build: 19 | runs-on: ubuntu-latest 20 | env: 21 | GITHUB_TOKEN: ${{ secrets.TOKEN }} 22 | steps: 23 | - uses: actions/checkout@v4 24 | 25 | - uses: subosito/flutter-action@v2 26 | with: 27 | cache: true 28 | channel: stable 29 | flutter-version: 3.38.4 30 | 31 | - name: Install dependencies 32 | run: flutter pub get 33 | 34 | - name: Verify formatting 35 | run: dart format --output=none --set-exit-if-changed . 36 | 37 | # Consider passing '--fatal-infos' for slightly stricter analysis. 38 | - name: Analyze project source 39 | run: flutter analyze 40 | 41 | # Your project will need to have tests in test/ and a dependency on 42 | # package:test for this step to succeed. Note that Flutter projects will 43 | # want to change this to 'flutter test'. 44 | - name: Run tests 45 | run: flutter test 46 | -------------------------------------------------------------------------------- /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 | @override 11 | String get name => 'precache'; 12 | 13 | @override 14 | String get description => 15 | 'Populate the flutterpi_tool\'s cache of binary artifacts.'; 16 | 17 | @override 18 | final String category = 'Flutter-Pi Tool'; 19 | 20 | @override 21 | Future runCommand() async { 22 | final os = switch (globals.os) { 23 | MoreOperatingSystemUtils os => os, 24 | _ => throw StateError( 25 | 'Operating system utils is not an FPiOperatingSystemUtils', 26 | ), 27 | }; 28 | 29 | final host = switch (os.fpiHostPlatform) { 30 | FlutterpiHostPlatform.windowsARM64 => FlutterpiHostPlatform.windowsX64, 31 | FlutterpiHostPlatform.darwinARM64 => FlutterpiHostPlatform.darwinX64, 32 | FlutterpiHostPlatform other => other 33 | }; 34 | 35 | // update the cached flutter-pi artifacts 36 | await flutterpiCache.updateAll( 37 | const {DevelopmentArtifact.universal}, 38 | offline: false, 39 | host: host, 40 | flutterpiPlatforms: FlutterpiTargetPlatform.values.toSet(), 41 | engineFlavors: EngineFlavor.values.toSet(), 42 | includeDebugSymbols: true, 43 | ); 44 | 45 | return FlutterCommandResult.success(); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /lib/src/cli/commands/run.dart: -------------------------------------------------------------------------------- 1 | // ignore_for_file: implementation_imports 2 | 3 | import 'package:file/file.dart'; 4 | import 'package:meta/meta.dart'; 5 | 6 | import 'package:flutterpi_tool/src/fltool/common.dart' as fltool; 7 | import 'package:flutterpi_tool/src/fltool/globals.dart' as globals; 8 | 9 | import 'package:flutterpi_tool/src/cli/flutterpi_command.dart'; 10 | import 'package:flutterpi_tool/src/artifacts.dart'; 11 | 12 | class RunCommand extends fltool.RunCommand with FlutterpiCommandMixin { 13 | RunCommand({bool verboseHelp = false}) { 14 | usesEngineFlavorOption(); 15 | usesDebugSymbolsOption(); 16 | usesLocalFlutterpiExecutableArg(verboseHelp: verboseHelp); 17 | } 18 | 19 | @protected 20 | @override 21 | Future createDebuggingOptions({ 22 | fltool.WebDevServerConfig? webDevServerConfig, 23 | }) async { 24 | final buildInfo = await getBuildInfo(); 25 | 26 | if (buildInfo.mode.isRelease) { 27 | return fltool.DebuggingOptions.disabled(buildInfo); 28 | } else { 29 | return fltool.DebuggingOptions.enabled(buildInfo); 30 | } 31 | } 32 | 33 | @override 34 | void addBuildModeFlags({ 35 | required bool verboseHelp, 36 | bool defaultToRelease = true, 37 | bool excludeDebug = false, 38 | bool excludeRelease = false, 39 | }) { 40 | // noop 41 | } 42 | 43 | @override 44 | Future runCommand() async { 45 | await populateCache(); 46 | 47 | var artifacts = globals.flutterpiArtifacts; 48 | if (getLocalFlutterpiExecutable() case File file) { 49 | artifacts = LocalFlutterpiBinaryOverride( 50 | inner: artifacts, 51 | flutterpiBinary: file, 52 | ); 53 | } 54 | 55 | return fltool.context.run( 56 | body: super.runCommand, 57 | overrides: { 58 | fltool.Artifacts: () => artifacts, 59 | }, 60 | ); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /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/mock_flutterpi_artifacts.dart: -------------------------------------------------------------------------------- 1 | import 'package:file/src/interface/file.dart'; 2 | import 'package:file/src/interface/file_system_entity.dart'; 3 | import 'package:flutter_tools/src/artifacts.dart'; 4 | import 'package:flutter_tools/src/build_info.dart'; 5 | import 'package:flutterpi_tool/src/artifacts.dart'; 6 | import 'package:test/test.dart'; 7 | 8 | class MockFlutterpiArtifacts implements FlutterpiArtifacts { 9 | String Function( 10 | Artifact, { 11 | TargetPlatform? platform, 12 | BuildMode? mode, 13 | EnvironmentType? environmentType, 14 | })? artifactPathFn; 15 | 16 | @override 17 | String getArtifactPath( 18 | Artifact artifact, { 19 | TargetPlatform? platform, 20 | BuildMode? mode, 21 | EnvironmentType? environmentType, 22 | }) { 23 | if (artifactPathFn == null) { 24 | fail("Expected getArtifactPath to not be called."); 25 | } 26 | return artifactPathFn!( 27 | artifact, 28 | platform: platform, 29 | mode: mode, 30 | environmentType: environmentType, 31 | ); 32 | } 33 | 34 | String Function(TargetPlatform platform, [BuildMode? mode])? getEngineTypeFn; 35 | 36 | @override 37 | String getEngineType(TargetPlatform platform, [BuildMode? mode]) { 38 | if (getEngineTypeFn == null) { 39 | fail("Expected getEngineType to not be called."); 40 | } 41 | return getEngineTypeFn!(platform, mode); 42 | } 43 | 44 | File Function(FlutterpiArtifact artifact)? getFlutterpiArtifactFn; 45 | 46 | @override 47 | File getFlutterpiArtifact(FlutterpiArtifact artifact) { 48 | if (getFlutterpiArtifactFn == null) { 49 | fail("Expected getFlutterpiArtifact to not be called."); 50 | } 51 | return getFlutterpiArtifactFn!(artifact); 52 | } 53 | 54 | FileSystemEntity Function(HostArtifact artifact)? getHostArtifactFn; 55 | 56 | @override 57 | FileSystemEntity getHostArtifact(HostArtifact artifact) { 58 | if (getHostArtifactFn == null) { 59 | fail("Expected getHostArtifact to not be called."); 60 | } 61 | return getHostArtifactFn!(artifact); 62 | } 63 | 64 | @override 65 | LocalEngineInfo? localEngineInfo; 66 | 67 | @override 68 | bool usesLocalArtifacts = false; 69 | } 70 | -------------------------------------------------------------------------------- /.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 | permissions: 10 | contents: read 11 | 12 | jobs: 13 | build: 14 | name: Build Flutter-Pi Bundle (${{ matrix.arch }}, ${{ matrix.cpu}}) 15 | runs-on: ubuntu-latest 16 | strategy: 17 | matrix: 18 | arch: 19 | - arm 20 | - arm64 21 | - x64 22 | - riscv64 23 | cpu: 24 | - generic 25 | include: 26 | - arch: arm 27 | cpu: pi3 28 | - arch: arm 29 | cpu: pi4 30 | - arch: arm64 31 | cpu: pi3 32 | - arch: arm64 33 | cpu: pi4 34 | steps: 35 | - uses: actions/checkout@v4 36 | 37 | - uses: subosito/flutter-action@v2 38 | with: 39 | cache: true 40 | channel: stable 41 | flutter-version: 3.38.4 42 | 43 | - name: Install dependencies & Activate as global executable 44 | run: | 45 | flutter pub get 46 | flutter pub global activate -spath . 47 | 48 | - name: Create test app 49 | run: flutter create test_app 50 | 51 | - name: Run flutterpi_tool build 52 | working-directory: test_app 53 | env: 54 | GITHUB_TOKEN: ${{ secrets.TOKEN }} 55 | run: | 56 | echo '::group::flutterpi_tool build ... --debug-unoptimized' 57 | flutterpi_tool build --arch=${{ matrix.arch }} --cpu=${{ matrix.cpu }} --debug-unoptimized --debug-symbols --verbose 58 | echo '::endgroup::' 59 | 60 | echo '::group::flutterpi_tool build ... --debug' 61 | flutterpi_tool build --arch=${{ matrix.arch }} --cpu=${{ matrix.cpu }} --debug --debug-symbols --verbose 62 | echo '::endgroup::' 63 | 64 | echo '::group::flutterpi_tool build ... --profile' 65 | flutterpi_tool build --arch=${{ matrix.arch }} --cpu=${{ matrix.cpu }} --profile --debug-symbols --verbose 66 | echo '::endgroup::' 67 | 68 | echo '::group::flutterpi_tool build ... --release' 69 | flutterpi_tool build --arch=${{ matrix.arch }} --cpu=${{ matrix.cpu }} --release --debug-symbols --verbose 70 | echo '::endgroup::' 71 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /lib/src/devices/flutterpi_ssh/device_discovery.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutterpi_tool/src/devices/flutterpi_ssh/device.dart'; 2 | import 'package:flutterpi_tool/src/fltool/common.dart'; 3 | import 'package:flutterpi_tool/src/config.dart'; 4 | import 'package:flutterpi_tool/src/more_os_utils.dart'; 5 | import 'package:flutterpi_tool/src/devices/flutterpi_ssh/ssh_utils.dart'; 6 | 7 | class FlutterpiSshDeviceDiscovery extends PollingDeviceDiscovery { 8 | FlutterpiSshDeviceDiscovery({ 9 | required this.sshUtils, 10 | required this.config, 11 | required this.logger, 12 | required this.os, 13 | }) : super('SSH Devices'); 14 | 15 | final SshUtils sshUtils; 16 | final FlutterPiToolConfig config; 17 | final Logger logger; 18 | final MoreOperatingSystemUtils os; 19 | 20 | @override 21 | bool get canListAnything => true; 22 | 23 | Future getDeviceIfReachable({ 24 | Duration? timeout, 25 | required DeviceConfigEntry configEntry, 26 | }) async { 27 | /// TODO: Use RemoteSpecificSshUtils here and verify 28 | final sshUtils = SshUtils( 29 | processUtils: this.sshUtils.processUtils, 30 | defaultRemote: configEntry.sshRemote, 31 | sshExecutable: configEntry.sshExecutable ?? this.sshUtils.sshExecutable, 32 | ); 33 | 34 | if (!await sshUtils.tryConnect(timeout: timeout)) { 35 | return null; 36 | } 37 | 38 | return FlutterpiSshDevice( 39 | id: configEntry.id, 40 | name: configEntry.id, 41 | sshUtils: sshUtils, 42 | remoteInstallPath: configEntry.remoteInstallPath, 43 | logger: logger, 44 | os: os, 45 | args: FlutterpiArgs( 46 | explicitDisplaySizeMillimeters: configEntry.displaySizeMillimeters, 47 | useDummyDisplay: configEntry.useDummyDisplay, 48 | dummyDisplaySize: configEntry.dummyDisplaySize, 49 | filesystemLayout: configEntry.filesystemLayout, 50 | ), 51 | ); 52 | } 53 | 54 | @override 55 | Future> pollingGetDevices({Duration? timeout}) async { 56 | timeout ??= Duration(seconds: 5); 57 | 58 | final entries = config.getDevices(); 59 | 60 | final devices = await Future.wait([ 61 | for (final entry in entries) 62 | getDeviceIfReachable(configEntry: entry, timeout: timeout), 63 | ]); 64 | 65 | devices.removeWhere((element) => element == null); 66 | 67 | return List.from(devices); 68 | } 69 | 70 | @override 71 | bool get supportsPlatform => true; 72 | 73 | @override 74 | List get wellKnownIds => const []; 75 | } 76 | -------------------------------------------------------------------------------- /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:flutterpi_tool/src/cli/flutterpi_command.dart'; 5 | import 'package:flutterpi_tool/src/fltool/common.dart'; 6 | 7 | class FlutterpiToolCommandRunner extends CommandRunner 8 | implements FlutterCommandRunner { 9 | FlutterpiToolCommandRunner({bool verboseHelp = false}) 10 | : super( 11 | 'flutterpi_tool', 12 | 'A tool to make development & distribution of flutter-pi apps easier.', 13 | usageLineLength: 120, 14 | ) { 15 | argParser.addOption( 16 | FlutterGlobalOptions.kPackagesOption, 17 | hide: true, 18 | help: 'Path to your "package_config.json" file.', 19 | ); 20 | 21 | argParser.addOption( 22 | FlutterGlobalOptions.kDeviceIdOption, 23 | abbr: 'd', 24 | help: 'Target device id or name (prefixes allowed).', 25 | ); 26 | 27 | argParser.addOption( 28 | FlutterGlobalOptions.kLocalWebSDKOption, 29 | hide: !verboseHelp, 30 | help: 31 | 'Name of a build output within the engine out directory, if you are building Flutter locally.\n' 32 | 'Use this to select a specific version of the web sdk if you have built multiple engine targets.\n' 33 | 'This path is relative to "--local-engine-src-path" (see above).', 34 | ); 35 | 36 | argParser.addFlag( 37 | FlutterGlobalOptions.kPrintDtd, 38 | negatable: false, 39 | help: 40 | 'Print the address of the Dart Tooling Daemon, if one is hosted by the Flutter CLI.', 41 | hide: !verboseHelp, 42 | ); 43 | } 44 | 45 | @override 46 | String get usageFooter => ''; 47 | 48 | @override 49 | List getRepoPackages() { 50 | throw UnimplementedError(); 51 | } 52 | 53 | @override 54 | List getRepoRoots() { 55 | throw UnimplementedError(); 56 | } 57 | 58 | @override 59 | void addCommand(Command command) { 60 | if (command.name != 'help' && command is! FlutterpiCommandMixin) { 61 | throw ArgumentError('Command is not a FlutterCommand: $command'); 62 | } 63 | 64 | super.addCommand(command); 65 | } 66 | 67 | @override 68 | Future run(Iterable args) { 69 | // This hacky rewriting cmdlines is also done in the upstream flutter tool. 70 | 71 | /// FIXME: This fails when options are specified. 72 | if (args.singleOrNull == 'devices') { 73 | args = ['devices', 'list']; 74 | } 75 | 76 | return super.run(args); 77 | } 78 | } 79 | 80 | abstract class FlutterpiCommand extends FlutterCommand 81 | with FlutterpiCommandMixin {} 82 | -------------------------------------------------------------------------------- /lib/src/executable.dart: -------------------------------------------------------------------------------- 1 | // ignore_for_file: avoid_print, implementation_imports 2 | 3 | import 'dart:async'; 4 | import 'package:args/command_runner.dart'; 5 | import 'package:flutterpi_tool/src/context.dart'; 6 | import 'package:flutterpi_tool/src/shutdown_hooks.dart'; 7 | import 'package:meta/meta.dart'; 8 | 9 | import 'package:flutterpi_tool/src/cache.dart'; 10 | import 'package:flutterpi_tool/src/cli/commands/build.dart'; 11 | import 'package:flutterpi_tool/src/cli/command_runner.dart'; 12 | import 'package:flutterpi_tool/src/cli/commands/devices.dart'; 13 | import 'package:flutterpi_tool/src/cli/commands/precache.dart'; 14 | import 'package:flutterpi_tool/src/cli/commands/run.dart'; 15 | import 'package:flutterpi_tool/src/cli/commands/test.dart'; 16 | 17 | import 'package:flutterpi_tool/src/fltool/common.dart' as fltool; 18 | import 'package:flutterpi_tool/src/fltool/globals.dart' as globals; 19 | 20 | @visibleForTesting 21 | FlutterpiToolCommandRunner createFlutterpiCommandRunner({ 22 | bool verboseHelp = false, 23 | }) { 24 | final runner = FlutterpiToolCommandRunner(verboseHelp: verboseHelp); 25 | 26 | runner.addCommand(BuildCommand(verboseHelp: verboseHelp)); 27 | runner.addCommand(PrecacheCommand()); 28 | runner.addCommand(DevicesCommand(verboseHelp: verboseHelp)); 29 | runner.addCommand(RunCommand(verboseHelp: verboseHelp)); 30 | runner.addCommand(TestCommand()); 31 | 32 | runner.argParser 33 | ..addSeparator('Other options') 34 | ..addFlag('verbose', negatable: false, help: 'Enable verbose logging.'); 35 | 36 | return runner; 37 | } 38 | 39 | Future main(List args) async { 40 | final verbose = 41 | args.contains('-v') || args.contains('--verbose') || args.contains('-vv'); 42 | final powershellHelpIndex = args.indexOf('-?'); 43 | if (powershellHelpIndex != -1) { 44 | args[powershellHelpIndex] = '-h'; 45 | } 46 | 47 | final help = args.contains('-h') || 48 | args.contains('--help') || 49 | (args.isNotEmpty && args.first == 'help') || 50 | (args.length == 1 && verbose); 51 | final verboseHelp = help && verbose; 52 | 53 | final runner = createFlutterpiCommandRunner(verboseHelp: verboseHelp); 54 | 55 | fltool.Cache.flutterRoot = await getFlutterRoot(); 56 | 57 | await runInContext( 58 | () async { 59 | try { 60 | await runner.run(args); 61 | 62 | await exitWithHooks( 63 | 0, 64 | shutdownHooks: globals.shutdownHooks, 65 | logger: globals.logger, 66 | ); 67 | } on fltool.ToolExit catch (e) { 68 | if (e.message != null) { 69 | globals.printError(e.message!); 70 | } 71 | 72 | await exitWithHooks( 73 | 0, 74 | shutdownHooks: globals.shutdownHooks, 75 | logger: globals.logger, 76 | ); 77 | } on UsageException catch (e) { 78 | globals.printError(e.message); 79 | globals.printStatus(e.usage); 80 | 81 | await exitWithHooks( 82 | 0, 83 | shutdownHooks: globals.shutdownHooks, 84 | logger: globals.logger, 85 | ); 86 | } 87 | }, 88 | verbose: verbose, 89 | ); 90 | } 91 | -------------------------------------------------------------------------------- /test/src/fake_os_utils.dart: -------------------------------------------------------------------------------- 1 | import 'package:file/file.dart'; 2 | import 'package:flutterpi_tool/src/archive.dart'; 3 | import 'package:flutterpi_tool/src/common.dart'; 4 | import 'package:flutterpi_tool/src/fltool/common.dart' as fl; 5 | import 'package:flutterpi_tool/src/more_os_utils.dart'; 6 | import 'package:test/fake.dart'; 7 | 8 | class FakeOperatingSystemUtils extends Fake implements fl.OperatingSystemUtils { 9 | FakeOperatingSystemUtils({this.hostPlatform = fl.HostPlatform.linux_x64}); 10 | 11 | final List> chmods = >[]; 12 | 13 | @override 14 | void makeExecutable(File file) {} 15 | 16 | @override 17 | fl.HostPlatform hostPlatform = fl.HostPlatform.linux_x64; 18 | 19 | @override 20 | void chmod(FileSystemEntity entity, String mode) { 21 | chmods.add([entity.path, mode]); 22 | } 23 | 24 | @override 25 | File? which(String execName) => null; 26 | 27 | @override 28 | List whichAll(String execName) => []; 29 | 30 | @override 31 | int? getDirectorySize(Directory directory) => 10000000; // 10 MB / 9.5 MiB 32 | 33 | @override 34 | void unzip(File file, Directory targetDirectory) {} 35 | 36 | @override 37 | void unpack(File gzippedTarFile, Directory targetDirectory) {} 38 | 39 | @override 40 | Stream> gzipLevel1Stream(Stream> stream) => stream; 41 | 42 | @override 43 | String get name => 'fake OS name and version'; 44 | 45 | @override 46 | String get pathVarSeparator => ';'; 47 | 48 | @override 49 | Future findFreePort({bool ipv6 = false}) async => 12345; 50 | } 51 | 52 | class FakeMoreOperatingSystemUtils extends Fake 53 | implements MoreOperatingSystemUtils { 54 | FakeMoreOperatingSystemUtils({ 55 | this.hostPlatform = fl.HostPlatform.linux_x64, 56 | this.fpiHostPlatform = FlutterpiHostPlatform.linuxX64, 57 | }); 58 | 59 | final List> chmods = >[]; 60 | 61 | @override 62 | void makeExecutable(File file) {} 63 | 64 | @override 65 | fl.HostPlatform hostPlatform; 66 | 67 | @override 68 | void chmod(FileSystemEntity entity, String mode) { 69 | chmods.add([entity.path, mode]); 70 | } 71 | 72 | @override 73 | File? which(String execName) => null; 74 | 75 | @override 76 | List whichAll(String execName) => []; 77 | 78 | @override 79 | int? getDirectorySize(Directory directory) => 10000000; // 10 MB / 9.5 MiB 80 | 81 | @override 82 | void unzip(File file, Directory targetDirectory) {} 83 | 84 | @override 85 | void unpack( 86 | File gzippedTarFile, 87 | Directory targetDirectory, { 88 | Archive Function(File)? decoder, 89 | ArchiveType? type, 90 | }) {} 91 | 92 | @override 93 | Stream> gzipLevel1Stream(Stream> stream) => stream; 94 | 95 | @override 96 | String get name => 'fake OS name and version'; 97 | 98 | @override 99 | String get pathVarSeparator => ';'; 100 | 101 | @override 102 | Future findFreePort({bool ipv6 = false}) async => 12345; 103 | 104 | @override 105 | final FlutterpiHostPlatform fpiHostPlatform; 106 | } 107 | -------------------------------------------------------------------------------- /test/src/fake_device_manager.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutterpi_tool/src/fltool/common.dart' as fl; 2 | import 'package:test/fake.dart'; 3 | 4 | class FakeDeviceManager implements fl.DeviceManager { 5 | var devices = []; 6 | 7 | @override 8 | String? specifiedDeviceId; 9 | 10 | @override 11 | bool get hasSpecifiedDeviceId => specifiedDeviceId != null; 12 | 13 | @override 14 | bool get hasSpecifiedAllDevices => specifiedDeviceId == null; 15 | 16 | @override 17 | Future> getAllDevices({ 18 | fl.DeviceDiscoveryFilter? filter, 19 | }) async { 20 | return await filter?.filterDevices(devices) ?? devices; 21 | } 22 | 23 | @override 24 | Future> refreshAllDevices({ 25 | Duration? timeout, 26 | fl.DeviceDiscoveryFilter? filter, 27 | }) async { 28 | return await getAllDevices(filter: filter); 29 | } 30 | 31 | @override 32 | Future> refreshExtendedWirelessDeviceDiscoverers({ 33 | Duration? timeout, 34 | fl.DeviceDiscoveryFilter? filter, 35 | }) async { 36 | return await getAllDevices(filter: filter); 37 | } 38 | 39 | @override 40 | Future> getDevicesById( 41 | String deviceId, { 42 | fl.DeviceDiscoveryFilter? filter, 43 | bool waitForDeviceToConnect = false, 44 | }) async { 45 | final devices = await getAllDevices(filter: filter); 46 | return devices.where((device) { 47 | return device.id == deviceId || device.id.startsWith(deviceId); 48 | }).toList(); 49 | } 50 | 51 | @override 52 | Future> getDevices({ 53 | fl.DeviceDiscoveryFilter? filter, 54 | bool waitForDeviceToConnect = false, 55 | }) { 56 | return hasSpecifiedDeviceId 57 | ? getDevicesById(specifiedDeviceId!, filter: filter) 58 | : getAllDevices(filter: filter); 59 | } 60 | 61 | @override 62 | bool get canListAnything => true; 63 | 64 | @override 65 | Future> getDeviceDiagnostics() async => []; 66 | 67 | @override 68 | List get deviceDiscoverers => []; 69 | 70 | @override 71 | fl.DeviceDiscoverySupportFilter deviceSupportFilter({ 72 | bool includeDevicesUnsupportedByProject = false, 73 | fl.FlutterProject? flutterProject, 74 | }) { 75 | fl.FlutterProject? flutterProject; 76 | if (!includeDevicesUnsupportedByProject) { 77 | flutterProject = fl.FlutterProject.current(); 78 | } 79 | if (hasSpecifiedAllDevices) { 80 | return fl.DeviceDiscoverySupportFilter 81 | .excludeDevicesUnsupportedByFlutterOrProjectOrAll( 82 | flutterProject: flutterProject, 83 | ); 84 | } else if (!hasSpecifiedDeviceId) { 85 | return fl.DeviceDiscoverySupportFilter 86 | .excludeDevicesUnsupportedByFlutterOrProject( 87 | flutterProject: flutterProject, 88 | ); 89 | } else { 90 | return fl.DeviceDiscoverySupportFilter 91 | .excludeDevicesUnsupportedByFlutter(); 92 | } 93 | } 94 | 95 | @override 96 | fl.Device? getSingleEphemeralDevice(List devices) => null; 97 | } 98 | 99 | class FakeFilter extends Fake implements fl.DeviceDiscoverySupportFilter { 100 | FakeFilter(); 101 | } 102 | -------------------------------------------------------------------------------- /test/commands/run_test.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import 'package:file/memory.dart'; 4 | import 'package:flutterpi_tool/src/cli/command_runner.dart'; 5 | import 'package:flutterpi_tool/src/executable.dart'; 6 | import 'package:flutterpi_tool/src/fltool/common.dart' as fl; 7 | import 'package:flutterpi_tool/src/build_system/build_app.dart'; 8 | import 'package:test/fake.dart'; 9 | import 'package:test/test.dart'; 10 | 11 | import '../src/context.dart'; 12 | import '../src/fake_device.dart'; 13 | import '../src/fake_device_manager.dart'; 14 | import '../src/fake_flutter_version.dart'; 15 | import '../src/fake_process_manager.dart'; 16 | import '../src/mock_app_builder.dart'; 17 | import '../src/mock_flutterpi_artifacts.dart'; 18 | import '../src/test_feature_flags.dart'; 19 | 20 | void main() { 21 | late MemoryFileSystem fs; 22 | late fl.BufferLogger logger; 23 | late FlutterpiToolCommandRunner runner; 24 | late fl.Platform platform; 25 | late MockFlutterpiArtifacts flutterpiArtifacts; 26 | late MockAppBuilder appBuilder; 27 | late FakeDeviceManager deviceManager; 28 | 29 | // ignore: no_leading_underscores_for_local_identifiers 30 | Future _runInTestContext( 31 | FutureOr Function() fn, { 32 | Map overrides = const {}, 33 | }) async { 34 | return await runInTestContext( 35 | fn, 36 | overrides: { 37 | fl.Logger: () => logger, 38 | ProcessManager: () => FakeProcessManager.empty(), 39 | fl.FileSystem: () => fs, 40 | fl.FlutterVersion: () => FakeFlutterVersion(), 41 | fl.Platform: () => platform, 42 | fl.Artifacts: () => flutterpiArtifacts, 43 | AppBuilder: () => appBuilder, 44 | fl.FeatureFlags: () => TestFeatureFlags(), 45 | fl.DeviceManager: () => deviceManager, 46 | fl.Terminal: () => fl.Terminal.test(), 47 | fl.AnsiTerminal: () => FakeTerminal(), 48 | ...overrides, 49 | }, 50 | ); 51 | } 52 | 53 | setUp(() { 54 | fs = MemoryFileSystem.test(); 55 | logger = fl.BufferLogger.test(); 56 | runner = createFlutterpiCommandRunner(); 57 | platform = fl.FakePlatform(); 58 | flutterpiArtifacts = MockFlutterpiArtifacts(); 59 | appBuilder = MockAppBuilder(); 60 | deviceManager = FakeDeviceManager(); 61 | 62 | fs.file('lib/main.dart') 63 | ..createSync(recursive: true) 64 | ..writeAsStringSync('void main() {}'); 65 | 66 | fs.file('pubspec.yaml').createSync(); 67 | }); 68 | 69 | test('specifying device id works', () async { 70 | deviceManager.devices.add( 71 | FakeDevice(id: 'test-device-2') 72 | ..isSupportedForProjectFn = ((_) => true) 73 | ..supportsRuntimeModeFn = ((_) => false), 74 | ); 75 | 76 | // This is fairly hacky, but works for now. 77 | try { 78 | await _runInTestContext(() async { 79 | await runner.run(['run', '-d', 'test-device', '--no-pub']); 80 | }); 81 | fail('Expected tool exit to be thrown.'); 82 | } on fl.ToolExit catch (e) { 83 | expect(e.message, 'Debugmode is not supported by Test Device.'); 84 | } 85 | 86 | expect(deviceManager.specifiedDeviceId, equals('test-device')); 87 | }); 88 | } 89 | 90 | class FakeTerminal extends Fake implements fl.AnsiTerminal { 91 | @override 92 | set usesTerminalUi(bool usesTerminalUi) {} 93 | } 94 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | - Building & Running apps on [meta-flutter](https://github.com/meta-flutter/meta-flutter) yocto distros works now, 6 | via the `--fs-layout=meta-flutter` option to `flutterpi_tool devices add`, `flutterpi_tool build`. 7 | - RISC-V 64-bit is now supported as a target & host platform. 8 | - The flutter-pi binary to bundle can now be explicitly specified using 9 | `--flutterpi-binary=...` 10 | 11 | ## Setup 12 | Setting up is as simple as: 13 | ```shell 14 | flutter pub global activate flutterpi_tool 15 | ``` 16 | 17 | `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. 18 | 19 | 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: 20 | 21 | ```shell 22 | flutter pub global activate flutterpi_tool ^0.3.0 23 | ``` 24 | 25 | If you are already using the latest stable flutter SDK, and the command still doesn't work, please open an issue! 26 | 27 | ## Usage 28 | ```console 29 | $ flutterpi_tool --help 30 | A tool to make development & distribution of flutter-pi apps easier. 31 | 32 | Usage: flutterpi_tool [arguments] 33 | 34 | Global options: 35 | -h, --help Print this usage information. 36 | -d, --device-id Target device id or name (prefixes allowed). 37 | 38 | Other options 39 | --verbose Enable verbose logging. 40 | 41 | Available commands: 42 | 43 | Flutter-Pi Tool 44 | precache Populate the flutterpi_tool's cache of binary artifacts. 45 | 46 | Project 47 | build Builds a flutter-pi asset bundle. 48 | run Run your Flutter app on an attached device. 49 | 50 | Tools & Devices 51 | devices List & manage flutterpi_tool devices. 52 | 53 | Run "flutterpi_tool help " for more information about a command. 54 | ``` 55 | 56 | ## Examples 57 | ### 1. Adding a device 58 | ```console 59 | $ flutterpi_tool devices add pi@pi5 60 | Device "pi5" has been added successfully. 61 | ``` 62 | 63 | ### 2. Adding a device with an explicit display size of 285x190mm, and a custom device name 64 | ```console 65 | $ flutterpi_tool devices add pi@pi5 --display-size=285x190 --id=my-pi 66 | Device "my-pi" has been added successfully. 67 | ``` 68 | 69 | ### 3. Adding a device that uses [meta-flutter](https://github.com/meta-flutter/meta-flutter) 70 | ```console 71 | $ flutterpi_tool devices add root@my-yocto-device --fs-layout=meta-flutter 72 | ``` 73 | 74 | ### 4. Listing devices 75 | ```console 76 | $ flutterpi_tool devices 77 | Found 1 wirelessly connected device: 78 | pi5 (mobile) • pi5 • linux-arm64 • Linux 79 | 80 | If you expected another device to be detected, try increasing the time to wait 81 | for connected devices by using the "flutterpi_tool devices list" command with 82 | the "--device-timeout" flag. 83 | ... 84 | ``` 85 | 86 | ### 5. Creating and running an app on a remote device 87 | ```console 88 | $ flutter create hello_world && cd hello_world 89 | 90 | $ flutterpi_tool run -d pi5 91 | Launching lib/main.dart on pi5 in debug mode... 92 | Building Flutter-Pi bundle... 93 | Installing app on device... 94 | ... 95 | ``` 96 | 97 | ### 6. Running the same app in profile mode 98 | ``` 99 | $ flutterpi_tool run -d pi5 --profile 100 | ``` 101 | -------------------------------------------------------------------------------- /test/src/test_feature_flags.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutterpi_tool/src/fltool/common.dart' as fl; 2 | 3 | class TestFeatureFlags implements fl.FeatureFlags { 4 | TestFeatureFlags({ 5 | this.isLinuxEnabled = false, 6 | this.isMacOSEnabled = false, 7 | this.isWebEnabled = false, 8 | this.isWindowsEnabled = false, 9 | this.isAndroidEnabled = true, 10 | this.isIOSEnabled = true, 11 | this.isFuchsiaEnabled = false, 12 | this.areCustomDevicesEnabled = false, 13 | this.isCliAnimationEnabled = true, 14 | this.isNativeAssetsEnabled = false, 15 | this.isSwiftPackageManagerEnabled = false, 16 | this.isOmitLegacyVersionFileEnabled = false, 17 | this.isLLDBDebuggingEnabled = false, 18 | this.isDartDataAssetsEnabled = false, 19 | this.isUISceneMigrationEnabled = false, 20 | this.isWindowingEnabled = false, 21 | }); 22 | 23 | @override 24 | final bool isLinuxEnabled; 25 | 26 | @override 27 | final bool isMacOSEnabled; 28 | 29 | @override 30 | final bool isWebEnabled; 31 | 32 | @override 33 | final bool isWindowsEnabled; 34 | 35 | @override 36 | final bool isAndroidEnabled; 37 | 38 | @override 39 | final bool isIOSEnabled; 40 | 41 | @override 42 | final bool isFuchsiaEnabled; 43 | 44 | @override 45 | final bool areCustomDevicesEnabled; 46 | 47 | @override 48 | final bool isCliAnimationEnabled; 49 | 50 | @override 51 | final bool isNativeAssetsEnabled; 52 | 53 | @override 54 | final bool isSwiftPackageManagerEnabled; 55 | 56 | @override 57 | final bool isOmitLegacyVersionFileEnabled; 58 | 59 | @override 60 | final bool isLLDBDebuggingEnabled; 61 | 62 | @override 63 | final bool isDartDataAssetsEnabled; 64 | 65 | @override 66 | final bool isUISceneMigrationEnabled; 67 | 68 | @override 69 | final bool isWindowingEnabled; 70 | 71 | @override 72 | bool isEnabled(fl.Feature feature) { 73 | return switch (feature) { 74 | fl.flutterWebFeature => isWebEnabled, 75 | fl.flutterLinuxDesktopFeature => isLinuxEnabled, 76 | fl.flutterMacOSDesktopFeature => isMacOSEnabled, 77 | fl.flutterWindowsDesktopFeature => isWindowsEnabled, 78 | fl.flutterAndroidFeature => isAndroidEnabled, 79 | fl.flutterIOSFeature => isIOSEnabled, 80 | fl.flutterFuchsiaFeature => isFuchsiaEnabled, 81 | fl.flutterCustomDevicesFeature => areCustomDevicesEnabled, 82 | fl.cliAnimation => isCliAnimationEnabled, 83 | fl.nativeAssets => isNativeAssetsEnabled, 84 | fl.swiftPackageManager => isSwiftPackageManagerEnabled, 85 | fl.omitLegacyVersionFile => isOmitLegacyVersionFileEnabled, 86 | fl.lldbDebugging => isLLDBDebuggingEnabled, 87 | fl.dartDataAssets => isDartDataAssetsEnabled, 88 | fl.uiSceneMigration => isUISceneMigrationEnabled, 89 | fl.windowingFeature => isWindowingEnabled, 90 | _ => false, 91 | }; 92 | } 93 | 94 | @override 95 | List get allFeatures => const [ 96 | fl.flutterWebFeature, 97 | fl.flutterLinuxDesktopFeature, 98 | fl.flutterMacOSDesktopFeature, 99 | fl.flutterWindowsDesktopFeature, 100 | fl.flutterAndroidFeature, 101 | fl.flutterIOSFeature, 102 | fl.flutterFuchsiaFeature, 103 | fl.flutterCustomDevicesFeature, 104 | fl.cliAnimation, 105 | fl.nativeAssets, 106 | fl.swiftPackageManager, 107 | fl.omitLegacyVersionFile, 108 | fl.lldbDebugging, 109 | fl.dartDataAssets, 110 | fl.uiSceneMigration, 111 | fl.windowingFeature, 112 | ]; 113 | 114 | @override 115 | Iterable get allConfigurableFeatures { 116 | return allFeatures 117 | .where((fl.Feature feature) => feature.configSetting != null); 118 | } 119 | 120 | @override 121 | Iterable get allEnabledFeatures { 122 | return allFeatures.where(isEnabled); 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /lib/src/context.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | import 'dart:io' as io; 3 | 4 | import 'package:flutterpi_tool/src/application_package_factory.dart'; 5 | import 'package:flutterpi_tool/src/artifacts.dart'; 6 | import 'package:flutterpi_tool/src/build_system/build_app.dart'; 7 | import 'package:flutterpi_tool/src/config.dart'; 8 | import 'package:flutterpi_tool/src/devices/device_manager.dart'; 9 | import 'package:flutterpi_tool/src/devices/flutterpi_ssh/ssh_utils.dart'; 10 | import 'package:unified_analytics/unified_analytics.dart'; 11 | import 'package:http/io_client.dart' as http; 12 | 13 | import 'package:flutterpi_tool/src/cache.dart'; 14 | import 'package:flutterpi_tool/src/fltool/common.dart' as fl; 15 | import 'package:flutterpi_tool/src/fltool/globals.dart' as globals; 16 | import 'package:flutterpi_tool/src/github.dart'; 17 | import 'package:flutterpi_tool/src/more_os_utils.dart'; 18 | 19 | // ignore: implementation_imports 20 | import 'package:flutter_tools/src/context_runner.dart' as fl; 21 | 22 | Future runInContext( 23 | FutureOr Function() fn, { 24 | bool verbose = false, 25 | }) async { 26 | return await fl.runInContext( 27 | fn, 28 | overrides: { 29 | Analytics: () => const NoOpAnalytics(), 30 | fl.TemplateRenderer: () => const fl.MustacheTemplateRenderer(), 31 | fl.Cache: () => FlutterpiCache( 32 | hooks: globals.shutdownHooks, 33 | logger: globals.logger, 34 | fileSystem: globals.fs, 35 | platform: globals.platform, 36 | osUtils: globals.os as MoreOperatingSystemUtils, 37 | projectFactory: globals.projectFactory, 38 | processManager: globals.processManager, 39 | github: MyGithub.caching( 40 | httpClient: http.IOClient( 41 | globals.httpClientFactory?.call() ?? io.HttpClient(), 42 | ), 43 | ), 44 | ), 45 | fl.OperatingSystemUtils: () => MoreOperatingSystemUtils( 46 | fileSystem: globals.fs, 47 | logger: globals.logger, 48 | platform: globals.platform, 49 | processManager: globals.processManager, 50 | ), 51 | fl.Logger: () { 52 | final f = fl.LoggerFactory( 53 | outputPreferences: globals.outputPreferences, 54 | terminal: globals.terminal, 55 | stdio: globals.stdio, 56 | ); 57 | 58 | return f.createLogger( 59 | daemon: false, 60 | machine: false, 61 | verbose: verbose, 62 | prefixedErrors: false, 63 | windows: globals.platform.isWindows, 64 | widgetPreviews: false, 65 | ); 66 | }, 67 | fl.Artifacts: () => CachedFlutterpiArtifacts( 68 | inner: fl.CachedArtifacts( 69 | fileSystem: globals.fs, 70 | platform: globals.platform, 71 | cache: globals.cache, 72 | operatingSystemUtils: globals.os, 73 | ), 74 | cache: globals.flutterpiCache, 75 | ), 76 | fl.Usage: () => fl.DisabledUsage(), 77 | FlutterPiToolConfig: () => FlutterPiToolConfig( 78 | fs: globals.fs, 79 | logger: globals.logger, 80 | platform: globals.platform, 81 | ), 82 | fl.BuildTargets: () => const fl.BuildTargetsImpl(), 83 | fl.ApplicationPackageFactory: () => FlutterpiApplicationPackageFactory(), 84 | fl.DeviceManager: () => FlutterpiToolDeviceManager( 85 | logger: globals.logger, 86 | platform: globals.platform, 87 | operatingSystemUtils: globals.os as MoreOperatingSystemUtils, 88 | sshUtils: globals.sshUtils, 89 | flutterpiToolConfig: globals.flutterPiToolConfig, 90 | ), 91 | AppBuilder: () => AppBuilder( 92 | operatingSystemUtils: globals.moreOs, 93 | buildSystem: globals.buildSystem, 94 | ), 95 | SshUtils: () => SshUtils( 96 | processUtils: globals.processUtils, 97 | defaultRemote: '', 98 | ), 99 | fl.FlutterHookRunner: () => fl.FlutterHookRunnerNative(), 100 | }, 101 | ); 102 | } 103 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /test/src/fake_flutter_version.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 | // Extraced from: https://github.com/flutter/flutter/blob/master/packages/flutter_tools/test/src/fakes.dart 6 | 7 | import 'package:file/file.dart'; 8 | import 'package:flutterpi_tool/src/fltool/common.dart' as fltool; 9 | 10 | class FakeFlutterVersion implements fltool.FlutterVersion { 11 | FakeFlutterVersion({ 12 | this.branch = 'master', 13 | this.dartSdkVersion = '12', 14 | this.devToolsVersion = '2.8.0', 15 | this.engineRevision = 'abcdefghijklmnopqrstuvwxyz', 16 | this.engineRevisionShort = 'abcde', 17 | this.engineAge = '0 hours ago', 18 | this.engineCommitDate = '12/01/01', 19 | this.repositoryUrl = 'https://github.com/flutter/flutter.git', 20 | this.frameworkVersion = '0.0.0', 21 | this.frameworkRevision = '11111111111111111111', 22 | this.frameworkRevisionShort = '11111', 23 | this.frameworkAge = '0 hours ago', 24 | this.frameworkCommitDate = '12/01/01', 25 | this.gitTagVersion = const fltool.GitTagVersion.unknown(), 26 | this.flutterRoot = '/path/to/flutter', 27 | this.nextFlutterVersion, 28 | this.engineBuildDate = '12/01/01', 29 | this.engineContentHash = 'abcdef', 30 | }); 31 | 32 | final String branch; 33 | 34 | bool get didFetchTagsAndUpdate => _didFetchTagsAndUpdate; 35 | bool _didFetchTagsAndUpdate = false; 36 | 37 | /// Will be returned by [fetchTagsAndGetVersion] if not null. 38 | final fltool.FlutterVersion? nextFlutterVersion; 39 | 40 | @override 41 | fltool.FlutterVersion fetchTagsAndGetVersion({ 42 | fltool.SystemClock clock = const fltool.SystemClock(), 43 | }) { 44 | _didFetchTagsAndUpdate = true; 45 | return nextFlutterVersion ?? this; 46 | } 47 | 48 | bool get didCheckFlutterVersionFreshness => _didCheckFlutterVersionFreshness; 49 | bool _didCheckFlutterVersionFreshness = false; 50 | 51 | @override 52 | String get channel { 53 | if (fltool.kOfficialChannels.contains(branch) || 54 | fltool.kObsoleteBranches.containsKey(branch)) { 55 | return branch; 56 | } 57 | return fltool.kUserBranch; 58 | } 59 | 60 | @override 61 | final String flutterRoot; 62 | 63 | @override 64 | final String devToolsVersion; 65 | 66 | @override 67 | final String dartSdkVersion; 68 | 69 | @override 70 | final String engineRevision; 71 | 72 | @override 73 | final String engineRevisionShort; 74 | 75 | @override 76 | final String? engineCommitDate; 77 | 78 | @override 79 | final String engineAge; 80 | 81 | @override 82 | final String? repositoryUrl; 83 | 84 | @override 85 | final String frameworkVersion; 86 | 87 | @override 88 | final String frameworkRevision; 89 | 90 | @override 91 | final String frameworkRevisionShort; 92 | 93 | @override 94 | final String frameworkAge; 95 | 96 | @override 97 | final String frameworkCommitDate; 98 | 99 | @override 100 | final fltool.GitTagVersion gitTagVersion; 101 | 102 | @override 103 | final String? engineBuildDate; 104 | 105 | @override 106 | final String? engineContentHash; 107 | 108 | @override 109 | FileSystem get fs => 110 | throw UnimplementedError('FakeFlutterVersion.fs is not implemented'); 111 | 112 | @override 113 | Future checkFlutterVersionFreshness() async { 114 | _didCheckFlutterVersionFreshness = true; 115 | } 116 | 117 | @override 118 | Future ensureVersionFile() async {} 119 | 120 | @override 121 | String getBranchName({bool redactUnknownBranches = false}) { 122 | if (!redactUnknownBranches || 123 | fltool.kOfficialChannels.contains(branch) || 124 | fltool.kObsoleteBranches.containsKey(branch)) { 125 | return branch; 126 | } 127 | return fltool.kUserBranch; 128 | } 129 | 130 | @override 131 | String getVersionString({bool redactUnknownBranches = false}) { 132 | return '${getBranchName(redactUnknownBranches: redactUnknownBranches)}/$frameworkRevision'; 133 | } 134 | 135 | @override 136 | Map toJson() { 137 | return {}; 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /test/src/mock_app_builder.dart: -------------------------------------------------------------------------------- 1 | import 'package:file/src/interface/directory.dart'; 2 | import 'package:flutterpi_tool/src/artifacts.dart'; 3 | import 'package:flutterpi_tool/src/build_system/build_app.dart'; 4 | import 'package:flutterpi_tool/src/cli/flutterpi_command.dart'; 5 | import 'package:flutterpi_tool/src/common.dart'; 6 | import 'package:flutterpi_tool/src/devices/flutterpi_ssh/device.dart'; 7 | import 'package:flutterpi_tool/src/fltool/common.dart' as fl; 8 | import 'package:test/test.dart'; 9 | 10 | class MockAppBuilder implements AppBuilder { 11 | Future Function({ 12 | required FlutterpiHostPlatform host, 13 | required FlutterpiTargetPlatform target, 14 | required fl.BuildInfo buildInfo, 15 | required FilesystemLayout fsLayout, 16 | fl.FlutterProject? project, 17 | FlutterpiArtifacts? artifacts, 18 | String? mainPath, 19 | String manifestPath, 20 | String? applicationKernelFilePath, 21 | String? depfilePath, 22 | Directory? outDir, 23 | bool unoptimized, 24 | bool includeDebugSymbols, 25 | bool forceBundleFlutterpi, 26 | })? buildFn; 27 | 28 | @override 29 | Future build({ 30 | required FlutterpiHostPlatform host, 31 | required FlutterpiTargetPlatform target, 32 | required fl.BuildInfo buildInfo, 33 | required FilesystemLayout fsLayout, 34 | fl.FlutterProject? project, 35 | FlutterpiArtifacts? artifacts, 36 | String? mainPath, 37 | String manifestPath = fl.defaultManifestPath, 38 | String? applicationKernelFilePath, 39 | String? depfilePath, 40 | Directory? outDir, 41 | bool unoptimized = false, 42 | bool includeDebugSymbols = false, 43 | bool forceBundleFlutterpi = false, 44 | }) { 45 | if (buildFn == null) { 46 | fail("Expected buildFn to not be called."); 47 | } 48 | 49 | return buildFn!( 50 | host: host, 51 | target: target, 52 | buildInfo: buildInfo, 53 | fsLayout: fsLayout, 54 | project: project, 55 | artifacts: artifacts, 56 | mainPath: mainPath, 57 | manifestPath: manifestPath, 58 | applicationKernelFilePath: applicationKernelFilePath, 59 | depfilePath: depfilePath, 60 | outDir: outDir, 61 | unoptimized: unoptimized, 62 | includeDebugSymbols: includeDebugSymbols, 63 | forceBundleFlutterpi: forceBundleFlutterpi, 64 | ); 65 | } 66 | 67 | Future Function({ 68 | required String id, 69 | required FlutterpiHostPlatform host, 70 | required FlutterpiTargetPlatform target, 71 | required fl.BuildInfo buildInfo, 72 | required FilesystemLayout fsLayout, 73 | fl.FlutterProject? project, 74 | FlutterpiArtifacts? artifacts, 75 | String? mainPath, 76 | String manifestPath, 77 | String? applicationKernelFilePath, 78 | String? depfilePath, 79 | bool unoptimized, 80 | bool includeDebugSymbols, 81 | bool forceBundleFlutterpi, 82 | })? buildBundleFn; 83 | 84 | @override 85 | Future buildBundle({ 86 | required String id, 87 | required FlutterpiHostPlatform host, 88 | required FlutterpiTargetPlatform target, 89 | required fl.BuildInfo buildInfo, 90 | required FilesystemLayout fsLayout, 91 | fl.FlutterProject? project, 92 | FlutterpiArtifacts? artifacts, 93 | String? mainPath, 94 | String manifestPath = fl.defaultManifestPath, 95 | String? applicationKernelFilePath, 96 | String? depfilePath, 97 | bool unoptimized = false, 98 | bool includeDebugSymbols = false, 99 | bool forceBundleFlutterpi = false, 100 | }) { 101 | if (buildBundleFn == null) { 102 | fail("Expected buildBundleFn to not be called."); 103 | } 104 | 105 | return buildBundleFn!( 106 | id: id, 107 | host: host, 108 | target: target, 109 | buildInfo: buildInfo, 110 | fsLayout: fsLayout, 111 | project: project, 112 | artifacts: artifacts, 113 | mainPath: mainPath, 114 | manifestPath: manifestPath, 115 | applicationKernelFilePath: applicationKernelFilePath, 116 | depfilePath: depfilePath, 117 | unoptimized: unoptimized, 118 | includeDebugSymbols: includeDebugSymbols, 119 | forceBundleFlutterpi: forceBundleFlutterpi, 120 | ); 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 0.10.1 - 2025-12-11 2 | 3 | - flutter 3.38.4 compatibility 4 | - fix hook results not rebuilding on hot reload 5 | - try to source github authentication token from GITHUB_TOKEN environment 6 | variable 7 | 8 | ## 0.10.0 - 2025-12-10 9 | - support flutter 3.38 10 | 11 | ## 0.9.2 - 2025-09-04 12 | - fix device diagnostics connecting to invalid device on `flutterpi_tool devices add` 13 | - fix target device specification using `flutterpi_tool run -d` 14 | 15 | ## 0.9.1 - 2025-09-01 16 | - fix artifacts resolving 17 | - add test for artifacts resolving 18 | 19 | ## 0.9.0 - 2025-08-29 20 | - flutter 3.35 compatibility 21 | 22 | ## 0.8.1 - 2025-08-29 23 | - add `--fs-layout=` argument to 24 | `flutterpi_tool devices add` and `flutterpi_tool build` 25 | - internal refactors, tests & improvements 26 | 27 | ## 0.8.0 - 2025-06-13 28 | - add `--flutterpi-binary` argument to bundle a custom flutter-pi binary 29 | with the app 30 | - flutter 3.32 compatibility 31 | - internal artifact resolving refactors 32 | 33 | ## 0.7.3 - 2025-04-29 34 | - add `flutterpi_tool test` subcommand 35 | - supports running integration tests on registered devices, e.g. 36 | - `flutterpi_tool test integration_test -d pi` 37 | - add `--dummy-display` and `--dummy-display-size` args for `flutterpi_tool devices add` 38 | - allows simulating a display, useful if no real display is attached 39 | 40 | ## 0.7.2 - 2025-04-29 41 | - add `flutterpi_tool test` subcommand 42 | - supports running integration tests on registered devices, e.g. 43 | - `flutterpi_tool test integration_test -d pi` 44 | - add `--dummy-display` and `--dummy-display-size` args for `flutterpi_tool devices add` 45 | - allows simulating a display, useful if no real display is attached 46 | 47 | ## 0.7.1 - 2025-03-21 48 | - fix missing executable permissions when running from windows 49 | - fix app not terminating when running from windows 50 | 51 | ## 0.7.0 - 2025-03-20 52 | - flutter 3.29 compatibility 53 | 54 | ## [0.6.0] - 2024-01-13 55 | - fix "artifact may not be available in some environments" warnings 56 | - 3.27 compatibility 57 | 58 | ## [0.5.4] - 2024-08-13 59 | - fix `flutterpi_tool run -d` command for flutter 3.24 60 | 61 | ## [0.5.3] - 2024-08-13 62 | - Fix artifact finding when github API results are paginated 63 | 64 | ## [0.5.2] - 2024-08-09 65 | - Flutter 3.24 compatibility 66 | - Print a nicer error message if engine artifacts are not yet available 67 | 68 | ## [0.5.1] - 2024-08-08 69 | - Expand remote user permissions check to `render` group, since that's necessary as well to use the hardware GPU. 70 | - 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. 71 | - Reduced the amount of GitHub API traffic generated when checking for updates to flutter-pi, to avoid rate limiting. 72 | - Changed the severity of the `failed to check for flutter-pi updates` message to a warning to avoid confusion. 73 | 74 | ## [0.5.0] - 2024-06-26 75 | 76 | - add `run` and `devices` subcommands 77 | - add persistent flutterpi_tool config for devices 78 | - update Readme 79 | - constrain to flutter 3.22.0 80 | 81 | ## [0.4.1] - 2024-06-15 82 | 83 | ### 📚 Documentation 84 | 85 | - Mention version conflicts in README 86 | 87 | ## 0.4.0 88 | 89 | - fix for flutter 3.22 90 | 91 | ## 0.3.0 92 | 93 | - fix for flutter 3.19 94 | 95 | ## 0.2.1 96 | 97 | - fix gen_snapshot selection 98 | 99 | ## 0.2.0 100 | 101 | - add macos host support 102 | - add `--dart-define`, `--dart-define-from-file`, `--target` flags 103 | - add `--debug-symbols` flag 104 | - add support for v2 artifact layout 105 | 106 | ## 0.1.2 107 | 108 | - update `flutterpi_tool --help` in readme 109 | 110 | ## 0.1.1 111 | 112 | - update `flutterpi_tool build help` in readme 113 | 114 | ## 0.1.0 115 | 116 | - add x64 support with `--arch=x64` (and `--cpu=generic`) 117 | - fix stale `app.so` when switching architectures (or cpus) 118 | - fix `--tree-shake-icons` defaults 119 | - fix inconsistent cached artifact versions 120 | 121 | ## 0.0.5 122 | 123 | - add `precache` command 124 | 125 | ## 0.0.4 126 | 127 | - update readme for new build option 128 | 129 | ## 0.0.3 130 | 131 | - remove some logging 132 | - add `--[no-]-tree-shake-icons` flag 133 | - sometimes tree shaking is impossible, in which case 134 | it's necessary to specify `--no-tree-shake-icons`, otherwise 135 | the tool will error 136 | 137 | ## 0.0.2 138 | 139 | - rename global executable `flutterpi-tool ==> flutterpi_tool` 140 | 141 | ## 0.0.1 142 | 143 | - Initial version. 144 | -------------------------------------------------------------------------------- /lib/src/build_system/extended_environment.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutterpi_tool/src/artifacts.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 FlutterpiArtifacts artifacts, 17 | required ProcessManager processManager, 18 | required Platform platform, 19 | required Analytics analytics, 20 | String? engineVersion, 21 | required bool generateDartPluginRegistry, 22 | Directory? buildDir, 23 | required MoreOperatingSystemUtils operatingSystemUtils, 24 | Map defines = const {}, 25 | Map inputs = const {}, 26 | }) { 27 | return ExtendedEnvironment.wrap( 28 | operatingSystemUtils: operatingSystemUtils, 29 | artifacts: artifacts, 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 | analytics: analytics, 42 | engineVersion: engineVersion, 43 | generateDartPluginRegistry: generateDartPluginRegistry, 44 | buildDir: buildDir, 45 | defines: defines, 46 | inputs: inputs, 47 | ), 48 | ); 49 | } 50 | 51 | ExtendedEnvironment.wrap({ 52 | required this.operatingSystemUtils, 53 | required this.artifacts, 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 | Directory get buildDir => _delegate.buildDir; 64 | 65 | @override 66 | Directory get cacheDir => _delegate.cacheDir; 67 | 68 | @override 69 | Map get defines => _delegate.defines; 70 | 71 | @override 72 | DepfileService get depFileService => _delegate.depFileService; 73 | 74 | @override 75 | String? get engineVersion => _delegate.engineVersion; 76 | 77 | @override 78 | FileSystem get fileSystem => _delegate.fileSystem; 79 | 80 | @override 81 | Directory get flutterRootDir => _delegate.flutterRootDir; 82 | 83 | @override 84 | bool get generateDartPluginRegistry => _delegate.generateDartPluginRegistry; 85 | 86 | @override 87 | Map get inputs => _delegate.inputs; 88 | 89 | @override 90 | Logger get logger => _delegate.logger; 91 | 92 | @override 93 | Directory get outputDir => _delegate.outputDir; 94 | 95 | @override 96 | Platform get platform => _delegate.platform; 97 | 98 | @override 99 | ProcessManager get processManager => _delegate.processManager; 100 | 101 | @override 102 | Directory get projectDir => _delegate.projectDir; 103 | 104 | @override 105 | String get packageConfigPath => _delegate.packageConfigPath; 106 | 107 | @override 108 | Directory get rootBuildDir => _delegate.rootBuildDir; 109 | 110 | @override 111 | final FlutterpiArtifacts artifacts; 112 | 113 | final MoreOperatingSystemUtils operatingSystemUtils; 114 | 115 | @override 116 | // In 3.38.4 this overrides, in 3.38.3 and before this is a new method. 117 | // ignore: override_on_non_overriding_member 118 | ExtendedEnvironment copyWith({Directory? outputDir}) { 119 | return ExtendedEnvironment.wrap( 120 | // can't use _delegate.copyWith because it doesn't exist in 3.38.3 and before 121 | delegate: Environment( 122 | projectDir: projectDir, 123 | packageConfigPath: packageConfigPath, 124 | outputDir: outputDir ?? this.outputDir, 125 | cacheDir: cacheDir, 126 | flutterRootDir: flutterRootDir, 127 | fileSystem: fileSystem, 128 | logger: logger, 129 | artifacts: artifacts, 130 | processManager: processManager, 131 | platform: platform, 132 | analytics: analytics, 133 | generateDartPluginRegistry: generateDartPluginRegistry, 134 | engineVersion: engineVersion, 135 | buildDir: buildDir, 136 | defines: defines, 137 | inputs: inputs, 138 | ), 139 | operatingSystemUtils: operatingSystemUtils, 140 | artifacts: artifacts, 141 | ); 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /test/src/mock_more_os_utils.dart: -------------------------------------------------------------------------------- 1 | import 'package:archive/src/archive.dart'; 2 | import 'package:file/src/interface/directory.dart'; 3 | import 'package:file/src/interface/file.dart'; 4 | import 'package:file/src/interface/file_system_entity.dart'; 5 | import 'package:flutter_tools/src/base/os.dart'; 6 | import 'package:flutterpi_tool/src/common.dart'; 7 | import 'package:flutterpi_tool/src/more_os_utils.dart'; 8 | import 'package:test/expect.dart'; 9 | 10 | class MockMoreOperatingSystemUtils implements MoreOperatingSystemUtils { 11 | MockMoreOperatingSystemUtils({ 12 | this.hostPlatform = HostPlatform.linux_x64, 13 | this.fpiHostPlatform = FlutterpiHostPlatform.linuxX64, 14 | this.name = 'test', 15 | this.pathVarSeparator = '/', 16 | }) : chmodFn = ((_, __) {}), 17 | findFreePortFn = (({bool ipv6 = false}) async => 1234), 18 | getDirectorySizeFn = ((_) => null), 19 | makeExecutableFn = ((_) {}), 20 | unpackFn = (( 21 | File gzippedTarFile, 22 | Directory targetDirectory, { 23 | ArchiveType? type, 24 | Archive Function(File p1)? decoder, 25 | }) {}), 26 | unzipFn = ((_, __) {}), 27 | whichFn = ((_) => null), 28 | whichAllFn = ((_) => []); 29 | 30 | MockMoreOperatingSystemUtils.empty({ 31 | this.hostPlatform = HostPlatform.linux_x64, 32 | this.fpiHostPlatform = FlutterpiHostPlatform.linuxX64, 33 | this.name = 'test', 34 | this.pathVarSeparator = '/', 35 | }); 36 | 37 | void Function(FileSystemEntity entity, String mode)? chmodFn; 38 | 39 | @override 40 | void chmod(FileSystemEntity entity, String mode) { 41 | if (chmodFn == null) { 42 | fail('Expected chmod to not be called.'); 43 | } 44 | chmodFn!(entity, mode); 45 | } 46 | 47 | Future Function({bool ipv6})? findFreePortFn; 48 | 49 | @override 50 | Future findFreePort({bool ipv6 = false}) { 51 | if (findFreePortFn == null) { 52 | fail('Expected findFreePort to not be called.'); 53 | } 54 | return findFreePortFn!(ipv6: ipv6); 55 | } 56 | 57 | @override 58 | FlutterpiHostPlatform fpiHostPlatform = FlutterpiHostPlatform.linuxX64; 59 | 60 | int? Function(Directory directory)? getDirectorySizeFn; 61 | 62 | @override 63 | int? getDirectorySize(Directory directory) { 64 | if (getDirectorySizeFn == null) { 65 | fail('Expected getDirectorySize to not be called.'); 66 | } 67 | return getDirectorySizeFn!(directory); 68 | } 69 | 70 | Stream> Function(Stream> stream)? gzipLevel1StreamFn; 71 | 72 | @override 73 | Stream> gzipLevel1Stream(Stream> stream) { 74 | if (gzipLevel1StreamFn == null) { 75 | fail('Expected gzipLevel1Stream to not be called.'); 76 | } 77 | return gzipLevel1StreamFn!(stream); 78 | } 79 | 80 | @override 81 | HostPlatform hostPlatform = HostPlatform.linux_x64; 82 | 83 | void Function(File file)? makeExecutableFn; 84 | 85 | @override 86 | void makeExecutable(File file) { 87 | if (makeExecutableFn == null) { 88 | fail('Expected makeExecutable to not be called.'); 89 | } 90 | makeExecutableFn!(file); 91 | } 92 | 93 | File Function(String path)? makePipeFn; 94 | 95 | @override 96 | File makePipe(String path) { 97 | if (makePipeFn == null) { 98 | fail('Expected makePipe to not be called.'); 99 | } 100 | return makePipeFn!(path); 101 | } 102 | 103 | @override 104 | String name = 'fake OS name and version'; 105 | 106 | @override 107 | String pathVarSeparator = ';'; 108 | 109 | void Function( 110 | File gzippedTarFile, 111 | Directory targetDirectory, { 112 | ArchiveType? type, 113 | Archive Function(File p1)? decoder, 114 | })? unpackFn; 115 | 116 | @override 117 | void unpack( 118 | File gzippedTarFile, 119 | Directory targetDirectory, { 120 | ArchiveType? type, 121 | Archive Function(File p1)? decoder, 122 | }) { 123 | if (unpackFn == null) { 124 | fail('Expected unpack to not be called.'); 125 | } 126 | unpackFn!( 127 | gzippedTarFile, 128 | targetDirectory, 129 | type: type, 130 | decoder: decoder, 131 | ); 132 | } 133 | 134 | void Function(File file, Directory targetDirectory)? unzipFn; 135 | 136 | @override 137 | void unzip(File file, Directory targetDirectory) { 138 | if (unzipFn == null) { 139 | fail('Expected unzip to not be called.'); 140 | } 141 | unzipFn!(file, targetDirectory); 142 | } 143 | 144 | File? Function(String path)? whichFn; 145 | 146 | @override 147 | File? which(String execName) { 148 | if (whichFn == null) { 149 | fail('Expected which to not be called.'); 150 | } 151 | return whichFn!(execName); 152 | } 153 | 154 | List Function(String execName)? whichAllFn; 155 | 156 | @override 157 | List whichAll(String execName) { 158 | if (whichAllFn == null) { 159 | fail('Expected whichAll to not be called.'); 160 | } 161 | return whichAllFn!(execName); 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /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/fltool/common.dart: -------------------------------------------------------------------------------- 1 | export 'package:flutter_tools/executable.dart'; 2 | export 'package:flutter_tools/src/android/android_builder.dart'; 3 | export 'package:flutter_tools/src/android/android_sdk.dart'; 4 | export 'package:flutter_tools/src/android/android_studio.dart'; 5 | export 'package:flutter_tools/src/android/android_workflow.dart'; 6 | export 'package:flutter_tools/src/android/gradle.dart'; 7 | export 'package:flutter_tools/src/android/gradle_utils.dart'; 8 | export 'package:flutter_tools/src/android/java.dart'; 9 | export 'package:flutter_tools/src/application_package.dart'; 10 | export 'package:flutter_tools/src/artifacts.dart'; 11 | export 'package:flutter_tools/src/base/bot_detector.dart'; 12 | export 'package:flutter_tools/src/base/common.dart'; 13 | export 'package:flutter_tools/src/base/config.dart'; 14 | export 'package:flutter_tools/src/base/error_handling_io.dart'; 15 | export 'package:flutter_tools/src/base/file_system.dart'; 16 | export 'package:flutter_tools/src/base/io.dart'; 17 | export 'package:flutter_tools/src/base/logger.dart'; 18 | export 'package:flutter_tools/src/base/os.dart'; 19 | export 'package:flutter_tools/src/base/platform.dart'; 20 | export 'package:flutter_tools/src/base/process.dart' hide exitWithHooks; 21 | export 'package:flutter_tools/src/base/signals.dart'; 22 | export 'package:flutter_tools/src/base/template.dart'; 23 | export 'package:flutter_tools/src/base/terminal.dart'; 24 | export 'package:flutter_tools/src/base/time.dart'; 25 | export 'package:flutter_tools/src/base/user_messages.dart'; 26 | export 'package:flutter_tools/src/base/version.dart'; 27 | export 'package:flutter_tools/src/build_info.dart'; 28 | export 'package:flutter_tools/src/build_system/build_system.dart'; 29 | export 'package:flutter_tools/src/build_system/build_targets.dart'; 30 | export 'package:flutter_tools/src/build_system/depfile.dart'; 31 | export 'package:flutter_tools/src/build_system/targets/common.dart'; 32 | export 'package:flutter_tools/src/bundle.dart'; 33 | export 'package:flutter_tools/src/cache.dart'; 34 | export 'package:flutter_tools/src/commands/devices.dart' hide DevicesCommand; 35 | export 'package:flutter_tools/src/commands/run.dart'; 36 | export 'package:flutter_tools/src/commands/test.dart'; 37 | export 'package:flutter_tools/src/custom_devices/custom_device.dart'; 38 | export 'package:flutter_tools/src/custom_devices/custom_devices_config.dart'; 39 | export 'package:flutter_tools/src/dart/pub.dart'; 40 | export 'package:flutter_tools/src/devfs.dart'; 41 | export 'package:flutter_tools/src/device.dart'; 42 | export 'package:flutter_tools/src/device_port_forwarder.dart'; 43 | export 'package:flutter_tools/src/devtools_launcher.dart'; 44 | export 'package:flutter_tools/src/doctor.dart'; 45 | export 'package:flutter_tools/src/doctor_validator.dart'; 46 | export 'package:flutter_tools/src/emulator.dart'; 47 | export 'package:flutter_tools/src/features.dart'; 48 | export 'package:flutter_tools/src/flutter_application_package.dart'; 49 | export 'package:flutter_tools/src/flutter_cache.dart'; 50 | export 'package:flutter_tools/src/flutter_device_manager.dart'; 51 | export 'package:flutter_tools/src/flutter_features.dart'; 52 | export 'package:flutter_tools/src/ios/ios_workflow.dart'; 53 | export 'package:flutter_tools/src/ios/iproxy.dart'; 54 | export 'package:flutter_tools/src/ios/plist_parser.dart'; 55 | export 'package:flutter_tools/src/ios/simulators.dart'; 56 | export 'package:flutter_tools/src/ios/xcodeproj.dart'; 57 | export 'package:flutter_tools/src/isolated/build_targets.dart'; 58 | export 'package:flutter_tools/src/isolated/mustache_template.dart'; 59 | export 'package:flutter_tools/src/macos/cocoapods.dart'; 60 | export 'package:flutter_tools/src/macos/cocoapods_validator.dart'; 61 | export 'package:flutter_tools/src/macos/macos_workflow.dart'; 62 | export 'package:flutter_tools/src/macos/xcdevice.dart'; 63 | export 'package:flutter_tools/src/macos/xcode.dart'; 64 | export 'package:flutter_tools/src/mdns_discovery.dart'; 65 | export 'package:flutter_tools/src/persistent_tool_state.dart'; 66 | export 'package:flutter_tools/src/project.dart'; 67 | export 'package:flutter_tools/src/protocol_discovery.dart'; 68 | export 'package:flutter_tools/src/reporting/crash_reporting.dart'; 69 | export 'package:flutter_tools/src/reporting/first_run.dart'; 70 | export 'package:flutter_tools/src/reporting/reporting.dart'; 71 | export 'package:flutter_tools/src/reporting/unified_analytics.dart'; 72 | export 'package:flutter_tools/src/resident_runner.dart'; 73 | export 'package:flutter_tools/src/run_hot.dart'; 74 | export 'package:flutter_tools/src/runner/flutter_command.dart'; 75 | export 'package:flutter_tools/src/runner/flutter_command_runner.dart'; 76 | export 'package:flutter_tools/src/runner/local_engine.dart'; 77 | export 'package:flutter_tools/src/version.dart'; 78 | export 'package:flutter_tools/src/web/workflow.dart'; 79 | export 'package:flutter_tools/src/windows/visual_studio.dart'; 80 | export 'package:flutter_tools/src/windows/visual_studio_validator.dart'; 81 | export 'package:flutter_tools/src/windows/windows_workflow.dart'; 82 | export 'package:flutter_tools/src/base/process.dart'; 83 | export 'package:flutter_tools/src/asset.dart' hide defaultManifestPath; 84 | export 'package:flutter_tools/src/base/context.dart'; 85 | export 'package:flutter_tools/src/build_system/targets/icon_tree_shaker.dart'; 86 | export 'package:flutter_tools/src/build_system/exceptions.dart'; 87 | export 'package:flutter_tools/src/build_system/targets/assets.dart'; 88 | export 'package:flutter_tools/src/web/devfs_config.dart'; 89 | export 'package:flutter_tools/src/build_system/targets/native_assets.dart'; 90 | export 'package:flutter_tools/src/hook_runner.dart'; 91 | export 'package:flutter_tools/src/build_system/targets/hook_runner_native.dart'; 92 | -------------------------------------------------------------------------------- /lib/src/config.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutterpi_tool/src/cli/flutterpi_command.dart'; 2 | import 'package:flutterpi_tool/src/fltool/common.dart'; 3 | import 'package:meta/meta.dart'; 4 | 5 | class DeviceConfigEntry { 6 | const DeviceConfigEntry({ 7 | required this.id, 8 | required this.sshExecutable, 9 | required this.sshRemote, 10 | required this.remoteInstallPath, 11 | this.displaySizeMillimeters, 12 | this.devicePixelRatio, 13 | this.useDummyDisplay = false, 14 | this.dummyDisplaySize, 15 | this.filesystemLayout = FilesystemLayout.flutterPi, 16 | }); 17 | 18 | final String id; 19 | final String? sshExecutable; 20 | final String sshRemote; 21 | final String? remoteInstallPath; 22 | final (int, int)? displaySizeMillimeters; 23 | final double? devicePixelRatio; 24 | final bool useDummyDisplay; 25 | final (int, int)? dummyDisplaySize; 26 | final FilesystemLayout filesystemLayout; 27 | 28 | static DeviceConfigEntry fromMap(Map map) { 29 | return DeviceConfigEntry( 30 | id: map['id'] as String, 31 | sshExecutable: map['sshExecutable'] as String?, 32 | sshRemote: map['sshRemote'] as String, 33 | remoteInstallPath: map['remoteInstallPath'] as String?, 34 | displaySizeMillimeters: switch (map['displaySizeMillimeters']) { 35 | [num width, num height] => (width.round(), height.round()), 36 | _ => null, 37 | }, 38 | devicePixelRatio: (map['devicePixelRatio'] as num?)?.toDouble(), 39 | useDummyDisplay: map['useDummyDisplay'] as bool? ?? false, 40 | dummyDisplaySize: switch (map['dummyDisplaySize']) { 41 | [num width, num height] => (width.round(), height.round()), 42 | _ => null, 43 | }, 44 | filesystemLayout: switch (map['filesystemLayout']) { 45 | String string => FilesystemLayout.fromString(string), 46 | _ => FilesystemLayout.flutterPi, 47 | }, 48 | ); 49 | } 50 | 51 | Map toMap() { 52 | return { 53 | 'id': id, 54 | 'sshExecutable': sshExecutable, 55 | 'sshRemote': sshRemote, 56 | 'remoteInstallPath': remoteInstallPath, 57 | if (displaySizeMillimeters case (final width, final height)) 58 | 'displaySizeMillimeters': [width, height], 59 | if (devicePixelRatio case double devicePixelRatio) 60 | 'devicePixelRatio': devicePixelRatio, 61 | if (useDummyDisplay == true) 'useDummyDisplay': true, 62 | if (dummyDisplaySize case (final width, final height)) 63 | 'dummyDisplaySize': [width, height], 64 | if (filesystemLayout != FilesystemLayout.flutterPi) 65 | 'filesystemLayout': filesystemLayout.toString(), 66 | }; 67 | } 68 | 69 | @override 70 | bool operator ==(Object other) { 71 | if (other.runtimeType != DeviceConfigEntry) { 72 | return false; 73 | } 74 | 75 | final DeviceConfigEntry otherEntry = other as DeviceConfigEntry; 76 | 77 | return id == otherEntry.id && 78 | sshExecutable == otherEntry.sshExecutable && 79 | sshRemote == otherEntry.sshRemote && 80 | remoteInstallPath == otherEntry.remoteInstallPath && 81 | displaySizeMillimeters == otherEntry.displaySizeMillimeters && 82 | devicePixelRatio == otherEntry.devicePixelRatio && 83 | useDummyDisplay == otherEntry.useDummyDisplay && 84 | dummyDisplaySize == otherEntry.dummyDisplaySize && 85 | filesystemLayout == otherEntry.filesystemLayout; 86 | } 87 | 88 | @override 89 | int get hashCode => Object.hash( 90 | id, 91 | sshExecutable, 92 | sshRemote, 93 | remoteInstallPath, 94 | displaySizeMillimeters, 95 | devicePixelRatio, 96 | useDummyDisplay, 97 | dummyDisplaySize, 98 | filesystemLayout, 99 | ); 100 | 101 | @override 102 | String toString() { 103 | return 'DeviceConfigEntry(' 104 | 'id: $id, ' 105 | 'sshExecutable: $sshExecutable, ' 106 | 'sshRemote: $sshRemote, ' 107 | 'remoteInstallPath: $remoteInstallPath, ' 108 | 'displaySizeMillimeters: $displaySizeMillimeters, ' 109 | 'devicePixelRatio: $devicePixelRatio, ' 110 | 'useDummyDisplay: $useDummyDisplay, ' 111 | 'dummyDisplaySize: $dummyDisplaySize' 112 | ')'; 113 | } 114 | } 115 | 116 | class FlutterPiToolConfig { 117 | FlutterPiToolConfig({ 118 | required FileSystem fs, 119 | required Logger logger, 120 | required Platform platform, 121 | }) : _config = Config( 122 | 'flutterpi_tool_config', 123 | fileSystem: fs, 124 | logger: logger, 125 | platform: platform, 126 | ); 127 | 128 | @visibleForTesting 129 | FlutterPiToolConfig.test({ 130 | required FileSystem fs, 131 | required Logger logger, 132 | required Platform platform, 133 | }) : _config = Config.test( 134 | directory: fs.directory('/'), 135 | logger: logger, 136 | ); 137 | 138 | final Config _config; 139 | 140 | List getDevices() { 141 | final entries = _config.getValue('devices'); 142 | 143 | switch (entries) { 144 | case List entries: 145 | final devices = entries.whereType().map((entry) { 146 | return DeviceConfigEntry.fromMap(entry.cast()); 147 | }).toList(); 148 | 149 | return devices; 150 | default: 151 | return []; 152 | } 153 | } 154 | 155 | void _setDevices(List devices) { 156 | _config.setValue('devices', devices.map((e) => e.toMap()).toList()); 157 | } 158 | 159 | void addDevice(DeviceConfigEntry device) { 160 | _setDevices([...getDevices(), device]); 161 | } 162 | 163 | void removeDevice(String id) { 164 | final devices = getDevices(); 165 | 166 | devices.removeWhere((entry) => entry.id == id); 167 | 168 | _setDevices(devices); 169 | } 170 | 171 | bool containsDevice(String id) { 172 | return getDevices().any((element) => element.id == id); 173 | } 174 | } 175 | -------------------------------------------------------------------------------- /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/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/artifacts.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 '../../common.dart'; 13 | 14 | class BuildCommand extends FlutterpiCommand { 15 | static const archs = ['arm', 'arm64', 'x64', 'riscv64']; 16 | 17 | static const cpus = ['generic', 'pi3', 'pi4']; 18 | 19 | BuildCommand({bool verboseHelp = false}) { 20 | argParser.addSeparator( 21 | 'Runtime mode options (Defaults to debug. At most one can be specified)', 22 | ); 23 | 24 | usesEngineFlavorOption(); 25 | 26 | argParser 27 | ..addSeparator('Build options') 28 | ..addFlag( 29 | 'tree-shake-icons', 30 | help: 31 | 'Tree shake icon fonts so that only glyphs used by the application remain.', 32 | ); 33 | 34 | usesDebugSymbolsOption(); 35 | 36 | // add --dart-define, --dart-define-from-file options 37 | usesDartDefineOption(); 38 | usesTargetOption(); 39 | usesLocalFlutterpiExecutableArg(verboseHelp: verboseHelp); 40 | usesFilesystemLayoutArg(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 = globals.moreOs; 128 | 129 | // for windows arm64, darwin arm64, we just use the x64 variant 130 | final host = switch (os.fpiHostPlatform) { 131 | FlutterpiHostPlatform.windowsARM64 => FlutterpiHostPlatform.windowsX64, 132 | FlutterpiHostPlatform.darwinARM64 => FlutterpiHostPlatform.darwinX64, 133 | FlutterpiHostPlatform other => other 134 | }; 135 | 136 | var targetPlatform = getTargetPlatform(); 137 | 138 | if (buildMode == BuildMode.debug && !targetPlatform.isGeneric) { 139 | globals.logger.printTrace( 140 | 'Non-generic target platform ($targetPlatform) is not supported ' 141 | 'for debug mode, using generic variant ' 142 | '${targetPlatform.genericVariant}.', 143 | ); 144 | targetPlatform = targetPlatform.genericVariant; 145 | } 146 | 147 | // update the cached flutter-pi artifacts 148 | await flutterpiCache.updateAll( 149 | const {DevelopmentArtifact.universal}, 150 | host: host, 151 | offline: false, 152 | flutterpiPlatforms: {targetPlatform, targetPlatform.genericVariant}, 153 | runtimeModes: {buildMode}, 154 | engineFlavors: {flavor}, 155 | includeDebugSymbols: debugSymbols, 156 | ); 157 | 158 | FlutterpiArtifacts artifacts = FlutterToFlutterpiArtifactsForwarder( 159 | inner: globals.flutterpiArtifacts, 160 | host: host, 161 | target: targetPlatform, 162 | ); 163 | var forceBundleFlutterpi = false; 164 | if (getLocalFlutterpiExecutable() case File file) { 165 | artifacts = LocalFlutterpiBinaryOverride( 166 | inner: artifacts, 167 | flutterpiBinary: file, 168 | ); 169 | forceBundleFlutterpi = true; 170 | } 171 | 172 | // actually build the flutter bundle 173 | 174 | await globals.builder.build( 175 | host: host, 176 | target: targetPlatform, 177 | buildInfo: buildInfo, 178 | mainPath: targetFile, 179 | artifacts: artifacts, 180 | 181 | // for `--debug-unoptimized` build mode 182 | unoptimized: flavor.unoptimized, 183 | includeDebugSymbols: debugSymbols, 184 | 185 | fsLayout: filesystemLayout, 186 | forceBundleFlutterpi: forceBundleFlutterpi, 187 | ); 188 | 189 | return FlutterCommandResult.success(); 190 | } 191 | } 192 | -------------------------------------------------------------------------------- /test/config_test.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | 3 | import 'package:file/memory.dart'; 4 | import 'package:flutterpi_tool/src/config.dart'; 5 | import 'package:flutterpi_tool/src/fltool/common.dart'; 6 | import 'package:test/test.dart'; 7 | 8 | void main() { 9 | late MemoryFileSystem fs; 10 | late FakePlatform platform; 11 | late BufferLogger logger; 12 | late FlutterPiToolConfig config; 13 | 14 | setUp(() { 15 | fs = MemoryFileSystem.test(); 16 | platform = FakePlatform(); 17 | logger = BufferLogger.test(); 18 | config = 19 | FlutterPiToolConfig.test(fs: fs, logger: logger, platform: platform); 20 | }); 21 | 22 | test('adding a config', () { 23 | config.addDevice( 24 | DeviceConfigEntry( 25 | id: 'test-id', 26 | sshExecutable: 'test-ssh-executable', 27 | sshRemote: 'test-ssh-remote', 28 | remoteInstallPath: 'test-remote-install-path', 29 | displaySizeMillimeters: (1, 2), 30 | devicePixelRatio: 1.23, 31 | useDummyDisplay: true, 32 | dummyDisplaySize: (3, 4), 33 | ), 34 | ); 35 | 36 | final file = fs.file('.flutter_test'); 37 | 38 | // expect that the config file exists 39 | expect(file.existsSync(), isTrue); 40 | 41 | // expect that it's valid json 42 | late final dynamic json; 43 | expect( 44 | () => json = jsonDecode(file.readAsStringSync()), 45 | returnsNormally, 46 | ); 47 | 48 | expect( 49 | json, 50 | { 51 | 'devices': [ 52 | { 53 | 'id': 'test-id', 54 | 'sshExecutable': 'test-ssh-executable', 55 | 'sshRemote': 'test-ssh-remote', 56 | 'remoteInstallPath': 'test-remote-install-path', 57 | 'displaySizeMillimeters': [1, 2], 58 | 'devicePixelRatio': 1.23, 59 | 'useDummyDisplay': true, 60 | 'dummyDisplaySize': [3, 4], 61 | }, 62 | ], 63 | }, 64 | ); 65 | }); 66 | 67 | test('adding two configs', () { 68 | config.addDevice( 69 | DeviceConfigEntry( 70 | id: 'test-id', 71 | sshExecutable: 'test-ssh-executable', 72 | sshRemote: 'test-ssh-remote', 73 | remoteInstallPath: 'test-remote-install-path', 74 | displaySizeMillimeters: (1, 2), 75 | devicePixelRatio: 1.23, 76 | useDummyDisplay: false, 77 | dummyDisplaySize: (3, 4), 78 | ), 79 | ); 80 | config.addDevice( 81 | DeviceConfigEntry( 82 | id: 'test-id-2', 83 | sshExecutable: 'test-ssh-executable', 84 | sshRemote: 'test-ssh-remote', 85 | remoteInstallPath: 'test-remote-install-path', 86 | displaySizeMillimeters: (1, 2), 87 | devicePixelRatio: 1.23, 88 | useDummyDisplay: false, 89 | dummyDisplaySize: (3, 4), 90 | ), 91 | ); 92 | 93 | expect( 94 | jsonDecode(fs.file('.flutter_test').readAsStringSync()), 95 | { 96 | 'devices': [ 97 | { 98 | 'id': 'test-id', 99 | 'sshExecutable': 'test-ssh-executable', 100 | 'sshRemote': 'test-ssh-remote', 101 | 'remoteInstallPath': 'test-remote-install-path', 102 | 'displaySizeMillimeters': [1, 2], 103 | 'devicePixelRatio': 1.23, 104 | 'dummyDisplaySize': [3, 4], 105 | }, 106 | { 107 | 'id': 'test-id-2', 108 | 'sshExecutable': 'test-ssh-executable', 109 | 'sshRemote': 'test-ssh-remote', 110 | 'remoteInstallPath': 'test-remote-install-path', 111 | 'displaySizeMillimeters': [1, 2], 112 | 'devicePixelRatio': 1.23, 113 | 'dummyDisplaySize': [3, 4], 114 | }, 115 | ], 116 | }, 117 | ); 118 | }); 119 | 120 | test('removing a config', () { 121 | config.addDevice( 122 | DeviceConfigEntry( 123 | id: 'test-id', 124 | sshExecutable: 'test-ssh-executable', 125 | sshRemote: 'test-ssh-remote', 126 | remoteInstallPath: 'test-remote-install-path', 127 | displaySizeMillimeters: (1, 2), 128 | devicePixelRatio: 1.23, 129 | useDummyDisplay: false, 130 | dummyDisplaySize: (3, 4), 131 | ), 132 | ); 133 | config.addDevice( 134 | DeviceConfigEntry( 135 | id: 'test-id-2', 136 | sshExecutable: 'test-ssh-executable', 137 | sshRemote: 'test-ssh-remote', 138 | remoteInstallPath: 'test-remote-install-path', 139 | displaySizeMillimeters: (1, 2), 140 | devicePixelRatio: 1.23, 141 | useDummyDisplay: false, 142 | dummyDisplaySize: (3, 4), 143 | ), 144 | ); 145 | 146 | config.removeDevice('test-id'); 147 | 148 | expect( 149 | jsonDecode(fs.file('.flutter_test').readAsStringSync()), 150 | { 151 | 'devices': [ 152 | { 153 | 'id': 'test-id-2', 154 | 'sshExecutable': 'test-ssh-executable', 155 | 'sshRemote': 'test-ssh-remote', 156 | 'remoteInstallPath': 'test-remote-install-path', 157 | 'displaySizeMillimeters': [1, 2], 158 | 'devicePixelRatio': 1.23, 159 | 'dummyDisplaySize': [3, 4], 160 | }, 161 | ], 162 | }, 163 | ); 164 | }); 165 | 166 | test('listing configs', () { 167 | config.addDevice( 168 | DeviceConfigEntry( 169 | id: 'test-id', 170 | sshExecutable: 'test-ssh-executable', 171 | sshRemote: 'test-ssh-remote', 172 | remoteInstallPath: 'test-remote-install-path', 173 | displaySizeMillimeters: (1, 2), 174 | devicePixelRatio: 1.23, 175 | useDummyDisplay: false, 176 | dummyDisplaySize: (3, 4), 177 | ), 178 | ); 179 | config.addDevice( 180 | DeviceConfigEntry( 181 | id: 'test-id-2', 182 | sshExecutable: 'test-ssh-executable', 183 | sshRemote: 'test-ssh-remote', 184 | remoteInstallPath: 'test-remote-install-path', 185 | displaySizeMillimeters: (1, 2), 186 | devicePixelRatio: 1.23, 187 | useDummyDisplay: false, 188 | dummyDisplaySize: (3, 4), 189 | ), 190 | ); 191 | 192 | expect( 193 | config.getDevices(), 194 | unorderedEquals( 195 | [ 196 | DeviceConfigEntry( 197 | id: 'test-id', 198 | sshExecutable: 'test-ssh-executable', 199 | sshRemote: 'test-ssh-remote', 200 | remoteInstallPath: 'test-remote-install-path', 201 | displaySizeMillimeters: (1, 2), 202 | devicePixelRatio: 1.23, 203 | useDummyDisplay: false, 204 | dummyDisplaySize: (3, 4), 205 | ), 206 | DeviceConfigEntry( 207 | id: 'test-id-2', 208 | sshExecutable: 'test-ssh-executable', 209 | sshRemote: 'test-ssh-remote', 210 | remoteInstallPath: 'test-remote-install-path', 211 | displaySizeMillimeters: (1, 2), 212 | devicePixelRatio: 1.23, 213 | useDummyDisplay: false, 214 | dummyDisplaySize: (3, 4), 215 | ), 216 | ], 217 | ), 218 | ); 219 | }); 220 | 221 | test('contains device', () { 222 | config.addDevice( 223 | DeviceConfigEntry( 224 | id: 'test-id', 225 | sshExecutable: 'test-ssh-executable', 226 | sshRemote: 'test-ssh-remote', 227 | remoteInstallPath: 'test-remote-install-path', 228 | displaySizeMillimeters: (1, 2), 229 | devicePixelRatio: 1.23, 230 | useDummyDisplay: false, 231 | dummyDisplaySize: (3, 4), 232 | ), 233 | ); 234 | 235 | expect(config.containsDevice('test-id'), isTrue); 236 | expect(config.containsDevice('non-existing-id'), isFalse); 237 | }); 238 | } 239 | -------------------------------------------------------------------------------- /lib/src/artifacts.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutterpi_tool/src/build_system/extended_environment.dart'; 2 | import 'package:flutterpi_tool/src/cache.dart'; 3 | import 'package:flutterpi_tool/src/common.dart'; 4 | import 'package:flutterpi_tool/src/fltool/common.dart'; 5 | 6 | sealed class FlutterpiArtifact { 7 | const FlutterpiArtifact(); 8 | } 9 | 10 | final class FlutterpiBinary extends FlutterpiArtifact { 11 | const FlutterpiBinary({required this.target, required this.mode}); 12 | 13 | final FlutterpiTargetPlatform target; 14 | final BuildMode mode; 15 | } 16 | 17 | final class Engine extends FlutterpiArtifact { 18 | const Engine({required this.target, required this.flavor}); 19 | 20 | final FlutterpiTargetPlatform target; 21 | final EngineFlavor flavor; 22 | } 23 | 24 | final class EngineDebugSymbols extends FlutterpiArtifact { 25 | const EngineDebugSymbols({ 26 | required this.target, 27 | required this.flavor, 28 | }); 29 | 30 | final FlutterpiTargetPlatform target; 31 | final EngineFlavor flavor; 32 | } 33 | 34 | final class GenSnapshot extends FlutterpiArtifact { 35 | const GenSnapshot({ 36 | required this.host, 37 | required this.target, 38 | required this.mode, 39 | }) : assert(mode == BuildMode.release || mode == BuildMode.profile); 40 | 41 | final FlutterpiHostPlatform host; 42 | final FlutterpiTargetPlatform target; 43 | final BuildMode mode; 44 | } 45 | 46 | abstract class FlutterpiArtifacts implements Artifacts { 47 | File getFlutterpiArtifact(FlutterpiArtifact artifact); 48 | } 49 | 50 | class CachedFlutterpiArtifacts implements FlutterpiArtifacts { 51 | CachedFlutterpiArtifacts({ 52 | required this.inner, 53 | required this.cache, 54 | }); 55 | 56 | final Artifacts inner; 57 | final FlutterpiCache cache; 58 | 59 | @override 60 | String getArtifactPath( 61 | Artifact artifact, { 62 | TargetPlatform? platform, 63 | BuildMode? mode, 64 | EnvironmentType? environmentType, 65 | }) { 66 | return inner.getArtifactPath( 67 | artifact, 68 | platform: platform, 69 | mode: mode, 70 | environmentType: environmentType, 71 | ); 72 | } 73 | 74 | @override 75 | File getFlutterpiArtifact(FlutterpiArtifact artifact) { 76 | return switch (artifact) { 77 | FlutterpiBinary(:final target, :final mode) => cache 78 | .getArtifactDirectory('flutter-pi') 79 | .childDirectory(target.triple) 80 | .childDirectory( 81 | switch (mode) { 82 | BuildMode.debug => 'debug', 83 | BuildMode.profile || 84 | BuildMode.release || 85 | BuildMode.jitRelease => 86 | 'release', 87 | }, 88 | ) 89 | .childFile('flutter-pi'), 90 | Engine(:final target, :final flavor) => cache 91 | .getArtifactDirectory('engine') 92 | .childDirectory('flutterpi-engine-$target-$flavor') 93 | .childFile('libflutter_engine.so'), 94 | EngineDebugSymbols(:final target, :final flavor) => cache 95 | .getArtifactDirectory('engine') 96 | .childDirectory('flutterpi-engine-dbgsyms-$target-$flavor') 97 | .childFile('libflutter_engine.dbgsyms'), 98 | GenSnapshot(:final host, :final target, :final mode) => cache 99 | .getArtifactDirectory('engine') 100 | .childDirectory('flutterpi-gen-snapshot-$host-$target-$mode') 101 | .childFile('gen_snapshot') 102 | }; 103 | } 104 | 105 | @override 106 | String getEngineType(TargetPlatform platform, [BuildMode? mode]) { 107 | return inner.getEngineType(platform, mode); 108 | } 109 | 110 | @override 111 | FileSystemEntity getHostArtifact(HostArtifact artifact) { 112 | return inner.getHostArtifact(artifact); 113 | } 114 | 115 | @override 116 | LocalEngineInfo? get localEngineInfo => inner.localEngineInfo; 117 | 118 | @override 119 | bool get usesLocalArtifacts => inner.usesLocalArtifacts; 120 | } 121 | 122 | class FlutterpiArtifactsWrapper implements FlutterpiArtifacts { 123 | FlutterpiArtifactsWrapper({ 124 | required this.inner, 125 | }); 126 | 127 | final FlutterpiArtifacts inner; 128 | 129 | @override 130 | String getArtifactPath( 131 | Artifact artifact, { 132 | TargetPlatform? platform, 133 | BuildMode? mode, 134 | EnvironmentType? environmentType, 135 | }) { 136 | return inner.getArtifactPath( 137 | artifact, 138 | platform: platform, 139 | mode: mode, 140 | environmentType: environmentType, 141 | ); 142 | } 143 | 144 | @override 145 | File getFlutterpiArtifact(FlutterpiArtifact artifact) { 146 | return inner.getFlutterpiArtifact(artifact); 147 | } 148 | 149 | @override 150 | String getEngineType(TargetPlatform platform, [BuildMode? mode]) { 151 | return inner.getEngineType(platform, mode); 152 | } 153 | 154 | @override 155 | FileSystemEntity getHostArtifact(HostArtifact artifact) { 156 | return inner.getHostArtifact(artifact); 157 | } 158 | 159 | @override 160 | LocalEngineInfo? get localEngineInfo => inner.localEngineInfo; 161 | 162 | @override 163 | bool get usesLocalArtifacts => inner.usesLocalArtifacts; 164 | } 165 | 166 | class FlutterToFlutterpiArtifactsForwarder extends FlutterpiArtifactsWrapper { 167 | FlutterToFlutterpiArtifactsForwarder({ 168 | required super.inner, 169 | required this.host, 170 | required this.target, 171 | }); 172 | 173 | final FlutterpiHostPlatform host; 174 | final FlutterpiTargetPlatform target; 175 | 176 | @override 177 | String getArtifactPath( 178 | Artifact artifact, { 179 | TargetPlatform? platform, 180 | BuildMode? mode, 181 | EnvironmentType? environmentType, 182 | }) { 183 | return switch (artifact) { 184 | Artifact.genSnapshot => inner 185 | .getFlutterpiArtifact( 186 | GenSnapshot(host: host, target: target.genericVariant, mode: mode!), 187 | ) 188 | .path, 189 | _ => inner.getArtifactPath( 190 | artifact, 191 | platform: platform, 192 | mode: mode, 193 | environmentType: environmentType, 194 | ), 195 | }; 196 | } 197 | } 198 | 199 | class LocalFlutterpiBinaryOverride extends FlutterpiArtifactsWrapper { 200 | LocalFlutterpiBinaryOverride({ 201 | required super.inner, 202 | required this.flutterpiBinary, 203 | }); 204 | 205 | final File flutterpiBinary; 206 | 207 | @override 208 | File getFlutterpiArtifact(FlutterpiArtifact artifact) { 209 | return switch (artifact) { 210 | FlutterpiBinary _ => flutterpiBinary, 211 | _ => inner.getFlutterpiArtifact(artifact), 212 | }; 213 | } 214 | 215 | @override 216 | bool get usesLocalArtifacts => true; 217 | } 218 | 219 | extension _VisitFlutterpiArtifact on SourceVisitor { 220 | void visitFlutterpiArtifact(FlutterpiArtifact artifact) { 221 | final environment = this.environment; 222 | if (environment is! ExtendedEnvironment) { 223 | throw StateError( 224 | 'Expected environment to be a FlutterpiEnvironment, ' 225 | 'but got ${environment.runtimeType}.', 226 | ); 227 | } 228 | 229 | final artifactFile = environment.artifacts.getFlutterpiArtifact(artifact); 230 | assert(artifactFile.fileSystem == environment.fileSystem); 231 | 232 | sources.add(artifactFile); 233 | } 234 | } 235 | 236 | extension SourceFlutterpiArtifactSource on Source { 237 | static Source flutterpiArtifact(FlutterpiArtifact artifact) { 238 | return FlutterpiArtifactSource(artifact); 239 | } 240 | } 241 | 242 | class FlutterpiArtifactSource implements Source { 243 | final FlutterpiArtifact artifact; 244 | 245 | const FlutterpiArtifactSource( 246 | this.artifact, 247 | ); 248 | 249 | @override 250 | void accept(SourceVisitor visitor) { 251 | visitor.visitFlutterpiArtifact(artifact); 252 | } 253 | 254 | @override 255 | bool get implicit => false; 256 | } 257 | -------------------------------------------------------------------------------- /test/src/context.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 | 7 | import 'package:file/file.dart'; 8 | import 'package:file/memory.dart'; 9 | import 'package:flutterpi_tool/src/build_system/build_app.dart'; 10 | import 'package:flutterpi_tool/src/cache.dart'; 11 | import 'package:flutterpi_tool/src/config.dart'; 12 | import 'package:flutterpi_tool/src/devices/flutterpi_ssh/ssh_utils.dart'; 13 | import 'package:flutterpi_tool/src/github.dart'; 14 | import 'package:unified_analytics/unified_analytics.dart'; 15 | import 'package:test/test.dart' as test; 16 | 17 | import 'package:flutterpi_tool/src/fltool/common.dart' as fltool; 18 | import 'package:flutterpi_tool/src/fltool/globals.dart' as globals; 19 | 20 | import '../fake_github.dart'; 21 | import 'fake_doctor.dart'; 22 | import 'fake_process_manager.dart'; 23 | import 'mock_more_os_utils.dart'; 24 | 25 | Future runInThrowingContext( 26 | FutureOr Function() body, { 27 | Map overrides = const {}, 28 | }) { 29 | void fail(Type type) { 30 | test.fail('Should not access global $type.'); 31 | } 32 | 33 | return fltool.context.run( 34 | body: body, 35 | fallbacks: { 36 | fltool.AnsiTerminal: () => fail(fltool.AnsiTerminal), 37 | Analytics: () => fail(Analytics), 38 | fltool.AndroidBuilder: () => fail(fltool.AndroidBuilder), 39 | fltool.AndroidLicenseValidator: () => 40 | fail(fltool.AndroidLicenseValidator), 41 | fltool.AndroidSdk: () => fail(fltool.AndroidSdk), 42 | fltool.AndroidStudio: () => fail(fltool.AndroidStudio), 43 | fltool.AndroidValidator: () => fail(fltool.AndroidValidator), 44 | fltool.AndroidWorkflow: () => fail(fltool.AndroidWorkflow), 45 | fltool.ApplicationPackageFactory: () => 46 | fail(fltool.ApplicationPackageFactory), 47 | fltool.Artifacts: () => fail(fltool.Artifacts), 48 | fltool.AssetBundleFactory: () => fail(fltool.AssetBundleFactory), 49 | fltool.BotDetector: () => fail(fltool.BotDetector), 50 | fltool.BuildSystem: () => fail(fltool.BuildSystem), 51 | fltool.BuildTargets: () => fail(fltool.BuildTargets), 52 | fltool.Cache: () => fail(fltool.Cache), 53 | fltool.CocoaPods: () => fail(fltool.CocoaPods), 54 | fltool.CocoaPodsValidator: () => fail(fltool.CocoaPodsValidator), 55 | fltool.Config: () => fail(fltool.Config), 56 | fltool.CustomDevicesConfig: () => fail(fltool.CustomDevicesConfig), 57 | fltool.CrashReporter: () => fail(fltool.CrashReporter), 58 | fltool.DevFSConfig: () => fail(fltool.DevFSConfig), 59 | fltool.DeviceManager: () => fail(fltool.DeviceManager), 60 | fltool.DevtoolsLauncher: () => fail(fltool.DevtoolsLauncher), 61 | fltool.Doctor: () => fail(fltool.Doctor), 62 | fltool.DoctorValidatorsProvider: () => 63 | fail(fltool.DoctorValidatorsProvider), 64 | fltool.EmulatorManager: () => fail(fltool.EmulatorManager), 65 | fltool.FeatureFlags: () => fail(fltool.FeatureFlags), 66 | fltool.FlutterVersion: () => fail(fltool.FlutterVersion), 67 | fltool.FlutterCommand: () => fail(fltool.FlutterCommand), 68 | fltool.FlutterProjectFactory: () => fail(fltool.FlutterProjectFactory), 69 | fltool.FileSystem: () => fail(fltool.FileSystem), 70 | fltool.FileSystemUtils: () => fail(fltool.FileSystemUtils), 71 | fltool.GradleUtils: () => fail(fltool.GradleUtils), 72 | fltool.HotRunnerConfig: () => fail(fltool.HotRunnerConfig), 73 | fltool.IOSSimulatorUtils: () => fail(fltool.IOSSimulatorUtils), 74 | fltool.IOSWorkflow: () => fail(fltool.IOSWorkflow), 75 | fltool.Java: () => fail(fltool.Java), 76 | fltool.LocalEngineLocator: () => fail(fltool.LocalEngineLocator), 77 | fltool.Logger: () => fail(fltool.Logger), 78 | fltool.MacOSWorkflow: () => fail(fltool.MacOSWorkflow), 79 | fltool.MDnsVmServiceDiscovery: () => fail(fltool.MDnsVmServiceDiscovery), 80 | fltool.OperatingSystemUtils: () => fail(fltool.OperatingSystemUtils), 81 | fltool.OutputPreferences: () => fail(fltool.OutputPreferences), 82 | fltool.PersistentToolState: () => fail(fltool.PersistentToolState), 83 | fltool.ProcessInfo: () => fail(fltool.ProcessInfo), 84 | fltool.PlistParser: () => fail(fltool.PlistParser), 85 | ProcessManager: () => fail(ProcessManager), 86 | fltool.TemplateRenderer: () => fail(fltool.TemplateRenderer), 87 | fltool.Platform: () => fail(fltool.Platform), 88 | fltool.ProcessUtils: () => fail(fltool.ProcessUtils), 89 | fltool.Pub: () => fail(fltool.Pub), 90 | fltool.Stdio: () => fail(fltool.Stdio), 91 | fltool.SystemClock: () => fail(fltool.SystemClock), 92 | fltool.Signals: () => fail(fltool.Signals), 93 | fltool.Usage: () => fail(fltool.Usage), 94 | fltool.UserMessages: () => fail(fltool.UserMessages), 95 | fltool.VisualStudioValidator: () => fail(fltool.VisualStudioValidator), 96 | fltool.WebWorkflow: () => fail(fltool.WebWorkflow), 97 | fltool.WindowsWorkflow: () => fail(fltool.WindowsWorkflow), 98 | fltool.Xcode: () => fail(fltool.Xcode), 99 | fltool.XCDevice: () => fail(fltool.XCDevice), 100 | fltool.XcodeProjectInterpreter: () => 101 | fail(fltool.XcodeProjectInterpreter), 102 | 103 | // flutterpi_tool globals 104 | FlutterPiToolConfig: () => fail(FlutterPiToolConfig), 105 | SshUtils: () => fail(SshUtils), 106 | AppBuilder: () => fail(AppBuilder), 107 | }, 108 | 109 | // WebSocketConnector 110 | // VMServiceConnector 111 | // HttpClientFactory 112 | // MDnsVmServiceDiscovery 113 | // WebRunnerFactory 114 | // TemplatePathProvider 115 | // PreRunValidator 116 | // TestCompilerNativeAssetsBuilder 117 | 118 | overrides: overrides, 119 | ); 120 | } 121 | 122 | Future runInTestContext( 123 | FutureOr Function() body, { 124 | MyGithub? github, 125 | Map overrides = const {}, 126 | }) async { 127 | return await runInThrowingContext( 128 | () async { 129 | return await body(); 130 | }, 131 | overrides: { 132 | Analytics: () => const NoOpAnalytics(), 133 | fltool.Cache: () { 134 | final fs = globals.fs; 135 | fltool.Cache.flutterRoot = '/'; 136 | return FlutterpiCache.test( 137 | rootOverride: fs.directory('/cache')..createSync(), 138 | logger: globals.logger, 139 | fileSystem: fs, 140 | platform: globals.platform, 141 | osUtils: globals.moreOs, 142 | processManager: globals.processManager, 143 | hooks: globals.shutdownHooks, 144 | github: github ?? FakeGithub(), 145 | ); 146 | }, 147 | fltool.Logger: () => fltool.BufferLogger.test(), 148 | fltool.Platform: () => fltool.FakePlatform(), 149 | FileSystem: () => MemoryFileSystem.test(), 150 | fltool.FlutterProjectFactory: () => fltool.FlutterProjectFactory( 151 | logger: globals.logger, 152 | fileSystem: globals.fs, 153 | ), 154 | fltool.ApplicationPackageFactory: () => 155 | fltool.FlutterApplicationPackageFactory( 156 | androidSdk: globals.androidSdk, 157 | processManager: globals.processManager, 158 | logger: globals.logger, 159 | userMessages: globals.userMessages, 160 | fileSystem: globals.fs, 161 | ), 162 | fltool.AndroidSdk: fltool.AndroidSdk.locateAndroidSdk, 163 | ProcessManager: () => FakeProcessManager.empty(), 164 | fltool.UserMessages: () => fltool.UserMessages(), 165 | fltool.Config: () => fltool.Config.test(), 166 | fltool.FileSystemUtils: () => fltool.FileSystemUtils( 167 | fileSystem: globals.fs, 168 | platform: globals.platform, 169 | ), 170 | fltool.OperatingSystemUtils: () => MockMoreOperatingSystemUtils(), 171 | fltool.ProcessUtils: () => fltool.ProcessUtils( 172 | processManager: globals.processManager, 173 | logger: globals.logger, 174 | ), 175 | fltool.Doctor: () => FakeDoctor(globals.logger), 176 | ...overrides, 177 | }, 178 | ); 179 | } 180 | -------------------------------------------------------------------------------- /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:flutterpi_tool/src/fltool/common.dart'; 7 | import 'package:flutterpi_tool/src/more_os_utils.dart'; 8 | import 'package:meta/meta.dart'; 9 | 10 | @visibleForTesting 11 | String legalizePath(Uri url, FileSystem fileSystem) { 12 | final pieces = [url.host, ...url.pathSegments]; 13 | final convertedPieces = pieces.map(_legalizeName); 14 | return fileSystem.path.joinAll(convertedPieces); 15 | } 16 | 17 | String _legalizeName(String fileName) { 18 | const substitutions = { 19 | r'@': '@@', 20 | r'/': '@s@', 21 | r'\': '@bs@', 22 | r':': '@c@', 23 | r'%': '@per@', 24 | r'*': '@ast@', 25 | r'<': '@lt@', 26 | r'>': '@gt@', 27 | r'"': '@q@', 28 | r'|': '@pip@', 29 | r'?': '@ques@', 30 | }; 31 | 32 | final replaced = [ 33 | for (final codeUnit in fileName.codeUnits) 34 | if (substitutions[String.fromCharCode(codeUnit)] case String substitute) 35 | ...substitute.codeUnits 36 | else 37 | codeUnit, 38 | ]; 39 | 40 | return String.fromCharCodes(replaced); 41 | } 42 | 43 | class AuthenticatingArtifactUpdater implements ArtifactUpdater { 44 | AuthenticatingArtifactUpdater({ 45 | required MoreOperatingSystemUtils operatingSystemUtils, 46 | required Logger logger, 47 | required FileSystem fileSystem, 48 | required Directory tempStorage, 49 | required io.HttpClient httpClient, 50 | required Platform platform, 51 | required List allowedBaseUrls, 52 | }) : _operatingSystemUtils = operatingSystemUtils, 53 | _httpClient = httpClient, 54 | _logger = logger, 55 | _fileSystem = fileSystem, 56 | _tempStorage = tempStorage, 57 | _allowedBaseUrls = allowedBaseUrls; 58 | 59 | static const int _kRetryCount = 2; 60 | 61 | final Logger _logger; 62 | final MoreOperatingSystemUtils _operatingSystemUtils; 63 | final FileSystem _fileSystem; 64 | final Directory _tempStorage; 65 | final io.HttpClient _httpClient; 66 | 67 | final List _allowedBaseUrls; 68 | 69 | @override 70 | @visibleForTesting 71 | final List downloadedFiles = []; 72 | 73 | static const Set _denylistedBasenames = { 74 | 'entitlements.txt', 75 | 'without_entitlements.txt', 76 | }; 77 | void _removeDenylistedFiles(Directory directory) { 78 | for (final FileSystemEntity entity in directory.listSync(recursive: true)) { 79 | if (entity is! File) { 80 | continue; 81 | } 82 | if (_denylistedBasenames.contains(entity.basename)) { 83 | entity.deleteSync(); 84 | } 85 | } 86 | } 87 | 88 | @override 89 | Future downloadZipArchive( 90 | String message, 91 | Uri url, 92 | Directory location, { 93 | void Function(io.HttpClientRequest)? authenticate, 94 | }) { 95 | return _downloadArchive( 96 | message, 97 | url, 98 | location, 99 | _operatingSystemUtils.unzip, 100 | authenticate: authenticate, 101 | ); 102 | } 103 | 104 | @override 105 | Future downloadZippedTarball( 106 | String message, 107 | Uri url, 108 | Directory location, { 109 | void Function(io.HttpClientRequest)? authenticate, 110 | }) { 111 | return _downloadArchive( 112 | message, 113 | url, 114 | location, 115 | _operatingSystemUtils.unpack, 116 | authenticate: authenticate, 117 | ); 118 | } 119 | 120 | Future downloadArchive( 121 | String message, 122 | Uri url, 123 | Directory location, { 124 | void Function(io.HttpClientRequest)? authenticate, 125 | ArchiveType? archiveType, 126 | Archive Function(File)? decoder, 127 | }) { 128 | return _downloadArchive( 129 | message, 130 | url, 131 | location, 132 | (File file, Directory targetDirectory) { 133 | _operatingSystemUtils.unpack( 134 | file, 135 | targetDirectory, 136 | type: archiveType, 137 | decoder: decoder, 138 | ); 139 | }, 140 | authenticate: authenticate, 141 | ); 142 | } 143 | 144 | @override 145 | Future downloadFile(String message, Uri url, Directory location) { 146 | return _downloadArchive(message, url, location, (File file, Directory dir) { 147 | file.copySync(dir.childFile(file.basename).path); 148 | }); 149 | } 150 | 151 | Future _downloadArchive( 152 | String message, 153 | Uri url, 154 | Directory location, 155 | void Function(File, Directory) extractor, { 156 | void Function(io.HttpClientRequest)? authenticate, 157 | }) async { 158 | final downloadPath = legalizePath(url, _fileSystem); 159 | final tempFile = _createDownloadFile(downloadPath); 160 | 161 | var tries = _kRetryCount; 162 | while (tries > 0) { 163 | final status = _logger.startProgress(message); 164 | 165 | try { 166 | ErrorHandlingFileSystem.deleteIfExists(tempFile); 167 | if (!tempFile.parent.existsSync()) { 168 | tempFile.parent.createSync(recursive: true); 169 | } 170 | 171 | await _download(url, tempFile, status, authenticate: authenticate); 172 | 173 | if (!tempFile.existsSync()) { 174 | throw Exception('Did not find downloaded file ${tempFile.path}'); 175 | } 176 | } on Exception catch (err) { 177 | _logger.printTrace(err.toString()); 178 | tries -= 1; 179 | 180 | if (tries == 0) { 181 | throwToolExit( 182 | 'Failed to download $url. Ensure you have network connectivity and then try again.\n$err', 183 | ); 184 | } 185 | continue; 186 | } finally { 187 | status.stop(); 188 | } 189 | 190 | final destination = location.childDirectory( 191 | tempFile.fileSystem.path.basenameWithoutExtension(tempFile.path), 192 | ); 193 | 194 | ErrorHandlingFileSystem.deleteIfExists(destination, recursive: true); 195 | location.createSync(recursive: true); 196 | 197 | try { 198 | extractor(tempFile, location); 199 | } on Exception catch (err) { 200 | tries -= 1; 201 | if (tries == 0) { 202 | throwToolExit( 203 | 'Flutter could not download and/or extract $url. Ensure you have ' 204 | 'network connectivity and all of the required dependencies listed at ' 205 | 'flutter.dev/setup.\nThe original exception was: $err.', 206 | ); 207 | } 208 | 209 | ErrorHandlingFileSystem.deleteIfExists(tempFile); 210 | continue; 211 | } 212 | 213 | _removeDenylistedFiles(location); 214 | return; 215 | } 216 | } 217 | 218 | Future _download( 219 | Uri url, 220 | File file, 221 | Status status, { 222 | void Function(io.HttpClientRequest)? authenticate, 223 | }) async { 224 | final allowed = 225 | _allowedBaseUrls.any((baseUrl) => url.toString().startsWith(baseUrl)); 226 | 227 | // In tests make this a hard failure. 228 | assert( 229 | allowed, 230 | 'URL not allowed: $url\n' 231 | 'Allowed URLs must be based on one of: ${_allowedBaseUrls.join(', ')}', 232 | ); 233 | 234 | // In production, issue a warning but allow the download to proceed. 235 | if (!allowed) { 236 | status.pause(); 237 | _logger.printWarning( 238 | 'Downloading an artifact that may not be reachable in some environments (e.g. firewalled environments): $url\n' 239 | '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'); 240 | status.resume(); 241 | } 242 | 243 | final request = await _httpClient.getUrl(url); 244 | 245 | if (authenticate != null) { 246 | try { 247 | authenticate(request); 248 | } finally { 249 | request.close().ignore(); 250 | } 251 | } 252 | 253 | final response = await request.close(); 254 | 255 | if (response.statusCode != io.HttpStatus.ok) { 256 | throw Exception(response.statusCode); 257 | } 258 | 259 | final handle = file.openSync(mode: FileMode.writeOnly); 260 | try { 261 | await for (final chunk in response) { 262 | handle.writeFromSync(chunk); 263 | } 264 | } finally { 265 | handle.closeSync(); 266 | } 267 | } 268 | 269 | File _createDownloadFile(String name) { 270 | final path = _fileSystem.path.join(_tempStorage.path, name); 271 | final file = _fileSystem.file(path); 272 | downloadedFiles.add(file); 273 | return file; 274 | } 275 | 276 | @override 277 | void removeDownloadedFiles() { 278 | for (final file in downloadedFiles) { 279 | ErrorHandlingFileSystem.deleteIfExists(file); 280 | 281 | for (var directory = file.parent; 282 | directory.absolute.path != _tempStorage.absolute.path; 283 | directory = directory.parent) { 284 | // Handle race condition when the directory is deleted before this step 285 | 286 | if (directory.existsSync() && directory.listSync().isEmpty) { 287 | ErrorHandlingFileSystem.deleteIfExists(directory, recursive: true); 288 | } 289 | } 290 | } 291 | } 292 | } 293 | -------------------------------------------------------------------------------- /lib/src/build_system/build_app.dart: -------------------------------------------------------------------------------- 1 | import 'package:file/file.dart'; 2 | import 'package:flutterpi_tool/src/artifacts.dart'; 3 | import 'package:flutterpi_tool/src/build_system/extended_environment.dart'; 4 | import 'package:flutterpi_tool/src/build_system/targets.dart'; 5 | import 'package:flutterpi_tool/src/cli/flutterpi_command.dart'; 6 | import 'package:flutterpi_tool/src/common.dart'; 7 | import 'package:flutterpi_tool/src/devices/flutterpi_ssh/device.dart'; 8 | import 'package:flutterpi_tool/src/fltool/common.dart' as fl; 9 | import 'package:flutterpi_tool/src/fltool/globals.dart' as globals; 10 | import 'package:flutterpi_tool/src/more_os_utils.dart'; 11 | import 'package:unified_analytics/unified_analytics.dart'; 12 | 13 | class AppBuilder { 14 | AppBuilder({ 15 | MoreOperatingSystemUtils? operatingSystemUtils, 16 | fl.BuildSystem? buildSystem, 17 | }) : _buildSystem = buildSystem ?? globals.buildSystem, 18 | _operatingSystemUtils = operatingSystemUtils ?? globals.moreOs; 19 | 20 | final MoreOperatingSystemUtils _operatingSystemUtils; 21 | final fl.BuildSystem _buildSystem; 22 | 23 | Future build({ 24 | required FlutterpiHostPlatform host, 25 | required FlutterpiTargetPlatform target, 26 | required fl.BuildInfo buildInfo, 27 | required FilesystemLayout fsLayout, 28 | fl.FlutterProject? project, 29 | FlutterpiArtifacts? artifacts, 30 | String? mainPath, 31 | String manifestPath = fl.defaultManifestPath, 32 | String? applicationKernelFilePath, 33 | String? depfilePath, 34 | Directory? outDir, 35 | bool unoptimized = false, 36 | bool includeDebugSymbols = false, 37 | bool forceBundleFlutterpi = false, 38 | }) async { 39 | project ??= fl.FlutterProject.current(); 40 | mainPath ??= fl.defaultMainPath; 41 | depfilePath ??= fl.defaultDepfilePath; 42 | outDir ??= globals.fs.directory( 43 | globals.fs.path.join( 44 | fl.getBuildDirectory(), 45 | 'flutter-pi', 46 | switch (fsLayout) { 47 | FilesystemLayout.flutterPi => '$target', 48 | FilesystemLayout.metaFlutter => 'meta-flutter-$target', 49 | }, 50 | ), 51 | ); 52 | artifacts ??= globals.flutterpiArtifacts; 53 | 54 | // We can still build debug for non-generic platforms of course, the correct 55 | // (generic) target must be chosen in the caller in that case. 56 | if (!target.isGeneric && buildInfo.mode == fl.BuildMode.debug) { 57 | throw ArgumentError.value( 58 | buildInfo, 59 | 'buildInfo', 60 | 'Non-generic targets are not supported for debug mode.', 61 | ); 62 | } 63 | 64 | // If the precompiled flag was not passed, force us into debug mode. 65 | final environment = ExtendedEnvironment( 66 | projectDir: project.directory, 67 | packageConfigPath: buildInfo.packageConfigPath, 68 | outputDir: outDir, 69 | buildDir: project.dartTool.childDirectory('flutter_build'), 70 | cacheDir: globals.cache.getRoot(), 71 | flutterRootDir: globals.fs.directory(fl.Cache.flutterRoot), 72 | engineVersion: globals.artifacts!.usesLocalArtifacts 73 | ? null 74 | : globals.flutterVersion.engineRevision, 75 | analytics: NoOpAnalytics(), 76 | defines: { 77 | if (includeDebugSymbols) fl.kExtraGenSnapshotOptions: '--no-strip', 78 | 79 | // used by the KernelSnapshot target 80 | fl.kTargetPlatform: 81 | fl.getNameForTargetPlatform(fl.TargetPlatform.linux_arm64), 82 | fl.kTargetFile: mainPath, 83 | fl.kDeferredComponents: 'false', 84 | ...buildInfo.toBuildSystemEnvironment(), 85 | 86 | // The flutter_tool computes the `.dart_tool/` subdir name from the 87 | // build environment hash. 88 | // Adding a flutterpi-target entry here forces different subdirs for 89 | // different target platforms. 90 | // 91 | // If we don't have this, the flutter tool will happily reuse as much as 92 | // it can, and it determines it can reuse the `app.so` from (for example) 93 | // an arm build with an arm64 build, leading to errors. 94 | 'flutterpi-target': target.shortName, 95 | 'unoptimized': unoptimized.toString(), 96 | 'debug-symbols': includeDebugSymbols.toString(), 97 | }, 98 | artifacts: artifacts, 99 | fileSystem: globals.fs, 100 | logger: globals.logger, 101 | processManager: globals.processManager, 102 | platform: globals.platform, 103 | generateDartPluginRegistry: true, 104 | operatingSystemUtils: _operatingSystemUtils, 105 | ); 106 | 107 | final buildTarget = switch (buildInfo.mode) { 108 | fl.BuildMode.debug => DebugBundleFlutterpiAssets( 109 | target: target, 110 | unoptimized: unoptimized, 111 | debugSymbols: includeDebugSymbols, 112 | layout: fsLayout, 113 | forceBundleFlutterpi: forceBundleFlutterpi, 114 | ), 115 | fl.BuildMode.profile => ProfileBundleFlutterpiAssets( 116 | target: target, 117 | debugSymbols: includeDebugSymbols, 118 | layout: fsLayout, 119 | forceBundleFlutterpi: forceBundleFlutterpi, 120 | ), 121 | fl.BuildMode.release => ReleaseBundleFlutterpiAssets( 122 | target: target, 123 | debugSymbols: includeDebugSymbols, 124 | layout: fsLayout, 125 | forceBundleFlutterpi: forceBundleFlutterpi, 126 | ), 127 | _ => fl.throwToolExit('Unsupported build mode: ${buildInfo.mode}'), 128 | }; 129 | 130 | final status = 131 | globals.logger.startProgress('Building Flutter-Pi bundle...'); 132 | 133 | try { 134 | final result = await _buildSystem.build(buildTarget, environment); 135 | if (!result.success) { 136 | for (final measurement in result.exceptions.values) { 137 | globals.printError( 138 | 'Target ${measurement.target} failed: ${measurement.exception}', 139 | stackTrace: measurement.fatal ? measurement.stackTrace : null, 140 | ); 141 | } 142 | 143 | fl.throwToolExit('Failed to build bundle.'); 144 | } 145 | 146 | final depfile = fl.Depfile(result.inputFiles, result.outputFiles); 147 | final outputDepfile = globals.fs.file(depfilePath); 148 | if (!outputDepfile.parent.existsSync()) { 149 | outputDepfile.parent.createSync(recursive: true); 150 | } 151 | 152 | final depfileService = fl.DepfileService( 153 | fileSystem: globals.fs, 154 | logger: globals.logger, 155 | ); 156 | depfileService.writeToFile(depfile, outputDepfile); 157 | } finally { 158 | status.cancel(); 159 | } 160 | 161 | return; 162 | } 163 | 164 | Future buildBundle({ 165 | required String id, 166 | required FlutterpiHostPlatform host, 167 | required FlutterpiTargetPlatform target, 168 | required fl.BuildInfo buildInfo, 169 | required FilesystemLayout fsLayout, 170 | fl.FlutterProject? project, 171 | FlutterpiArtifacts? artifacts, 172 | String? mainPath, 173 | String manifestPath = fl.defaultManifestPath, 174 | String? applicationKernelFilePath, 175 | String? depfilePath, 176 | bool unoptimized = false, 177 | bool includeDebugSymbols = false, 178 | bool forceBundleFlutterpi = false, 179 | }) async { 180 | final buildDir = fl.getBuildDirectory(); 181 | 182 | final outPath = globals.fs.directory( 183 | globals.fs.path.join( 184 | buildDir, 185 | 'flutter-pi', 186 | switch (fsLayout) { 187 | FilesystemLayout.flutterPi => '$target', 188 | FilesystemLayout.metaFlutter => 'meta-flutter-$target', 189 | }, 190 | ), 191 | ); 192 | final outDir = globals.fs.directory(outPath); 193 | 194 | await build( 195 | host: host, 196 | target: target, 197 | buildInfo: buildInfo, 198 | fsLayout: fsLayout, 199 | artifacts: artifacts, 200 | mainPath: mainPath, 201 | manifestPath: manifestPath, 202 | applicationKernelFilePath: applicationKernelFilePath, 203 | depfilePath: depfilePath, 204 | outDir: outDir, 205 | unoptimized: unoptimized, 206 | includeDebugSymbols: includeDebugSymbols, 207 | forceBundleFlutterpi: forceBundleFlutterpi, 208 | ); 209 | 210 | final metaFlutterFlutterpiBin = 211 | outDir.childDirectory('bin').childFile('flutter-pi'); 212 | final metaFlutterDbgsyms = 213 | outDir.childDirectory('lib').childFile('libflutter_engine.dbgsyms'); 214 | 215 | return PrebuiltFlutterpiAppBundle( 216 | id: id, 217 | name: id, 218 | displayName: id, 219 | directory: outDir, 220 | 221 | // FIXME: This should be populated by the build targets instead. 222 | binaries: switch (fsLayout) { 223 | FilesystemLayout.flutterPi => [ 224 | outDir.childFile('flutter-pi'), 225 | outDir.childFile('libflutter_engine.so'), 226 | if (includeDebugSymbols) 227 | outDir.childFile('libflutter_engine.dbgsyms'), 228 | ], 229 | FilesystemLayout.metaFlutter => [ 230 | if (forceBundleFlutterpi) metaFlutterFlutterpiBin, 231 | outDir.childDirectory('lib').childFile('libflutter_engine.so'), 232 | if (includeDebugSymbols) metaFlutterDbgsyms, 233 | ], 234 | }, 235 | 236 | includesFlutterpiBinary: 237 | fsLayout == FilesystemLayout.flutterPi || forceBundleFlutterpi, 238 | ); 239 | } 240 | } 241 | -------------------------------------------------------------------------------- /test/src/fake_device.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import 'package:flutter_tools/src/base/dds.dart'; 4 | import 'package:flutter_tools/src/device_vm_service_discovery_for_attach.dart'; 5 | import 'package:flutterpi_tool/src/fltool/common.dart' as fl; 6 | import 'package:test/test.dart'; 7 | 8 | class FakeDevice implements fl.Device { 9 | fl.DevFSWriter? Function(fl.ApplicationPackage? app, String? userIdentifier)? 10 | createDevFSWriterFn; 11 | Future Function()? disposeFn; 12 | Future Function()? emulatorIdFn; 13 | FutureOr Function({ 14 | fl.ApplicationPackage? app, 15 | bool includePastLogs, 16 | })? getLogReaderFn; 17 | VMServiceDiscoveryForAttach Function({ 18 | String? appId, 19 | String? fuchsiaModule, 20 | int? filterDevicePort, 21 | int? expectedHostPort, 22 | required bool ipv6, 23 | required fl.Logger logger, 24 | })? getVMServiceDiscoveryForAttachFn; 25 | Future Function(fl.ApplicationPackage app, {String? userIdentifier})? 26 | installAppFn; 27 | Future Function(fl.ApplicationPackage app, {String? userIdentifier})? 28 | isAppInstalledFn; 29 | Future Function(fl.ApplicationPackage app)? isLatestBuildInstalledFn; 30 | Future Function()? isSupportedFn; 31 | bool Function(fl.FlutterProject flutterProject)? isSupportedForProjectFn; 32 | Future Function()? queryMemoryInfoFn; 33 | Future Function( 34 | fl.ApplicationPackage? package, { 35 | String? mainPath, 36 | String? route, 37 | required fl.DebuggingOptions debuggingOptions, 38 | Map platformArgs, 39 | bool prebuiltApplication, 40 | String? userIdentifier, 41 | })? startAppFn; 42 | Future Function(fl.ApplicationPackage? app, {String? userIdentifier})? 43 | stopAppFn; 44 | Future Function()? supportMessageFn; 45 | FutureOr Function(fl.BuildMode buildMode)? supportsRuntimeModeFn; 46 | Future Function(fl.File outputFile)? takeScreenshotFn; 47 | Future> Function()? toJsonFn; 48 | Future Function(fl.ApplicationPackage app, {String? userIdentifier})? 49 | uninstallAppFn; 50 | 51 | FakeDevice({ 52 | this.id = 'test-device', 53 | this.name = 'Test Device', 54 | this.displayName = 'Test Device', 55 | String? sdkNameAndVersion, 56 | fl.TargetPlatform? targetPlatform, 57 | String? targetPlatformDisplayName, 58 | this.category = fl.Category.mobile, 59 | this.platformType = fl.PlatformType.android, 60 | this.connectionInterface = fl.DeviceConnectionInterface.attached, 61 | }) : sdkNameAndVersion = Future.value(sdkNameAndVersion ?? 'Fake SDK 1.0.0'), 62 | targetPlatform = 63 | Future.value(targetPlatform ?? fl.TargetPlatform.android_arm64), 64 | targetPlatformDisplayName = 65 | Future.value(targetPlatformDisplayName ?? 'Android (arm64)'), 66 | isSupportedFn = (() => Future.value(true)); 67 | 68 | @override 69 | fl.Category? category; 70 | 71 | @override 72 | fl.DeviceConnectionInterface connectionInterface; 73 | 74 | DartDevelopmentService? _dds; 75 | 76 | set dds(DartDevelopmentService value) => _dds = value; 77 | 78 | @override 79 | DartDevelopmentService get dds { 80 | if (_dds == null) fail('Should not access dds'); 81 | return _dds!; 82 | } 83 | 84 | @override 85 | String displayName; 86 | 87 | @override 88 | bool ephemeral = false; 89 | 90 | @override 91 | String id; 92 | 93 | @override 94 | bool isConnected = true; 95 | 96 | bool localEmulator = false; 97 | 98 | @override 99 | Future get isLocalEmulator => Future.value(localEmulator); 100 | 101 | @override 102 | bool get isWirelesslyConnected { 103 | return connectionInterface == fl.DeviceConnectionInterface.wireless; 104 | } 105 | 106 | @override 107 | String name; 108 | 109 | @override 110 | fl.PlatformType platformType; 111 | 112 | @override 113 | fl.DevicePortForwarder? portForwarder; 114 | 115 | @override 116 | Future sdkNameAndVersion; 117 | 118 | @override 119 | bool supportsFlavors = true; 120 | 121 | @override 122 | bool supportsFlutterExit = true; 123 | 124 | @override 125 | Future supportsHardwareRendering = Future.value(true); 126 | 127 | @override 128 | bool supportsHotReload = true; 129 | 130 | @override 131 | bool supportsHotRestart = true; 132 | 133 | @override 134 | bool supportsScreenshot = true; 135 | 136 | @override 137 | bool supportsStartPaused = true; 138 | 139 | @override 140 | Future targetPlatform; 141 | 142 | @override 143 | Future targetPlatformDisplayName; 144 | 145 | // Methods and function fields 146 | @override 147 | void clearLogs() {} 148 | 149 | @override 150 | fl.DevFSWriter? createDevFSWriter( 151 | fl.ApplicationPackage? app, 152 | String? userIdentifier, 153 | ) { 154 | if (createDevFSWriterFn != null) { 155 | return createDevFSWriterFn!(app, userIdentifier); 156 | } 157 | fail('Should not access createDevFSWriter'); 158 | } 159 | 160 | @override 161 | Future dispose() { 162 | if (disposeFn != null) return disposeFn!(); 163 | fail('Should not access dispose'); 164 | } 165 | 166 | @override 167 | Future get emulatorId { 168 | if (emulatorIdFn != null) return emulatorIdFn!(); 169 | fail('Should not access emulatorId'); 170 | } 171 | 172 | @override 173 | FutureOr getLogReader({ 174 | fl.ApplicationPackage? app, 175 | bool includePastLogs = false, 176 | }) { 177 | if (getLogReaderFn != null) { 178 | return getLogReaderFn!(app: app, includePastLogs: includePastLogs); 179 | } 180 | fail('Should not access getLogReader'); 181 | } 182 | 183 | @override 184 | VMServiceDiscoveryForAttach getVMServiceDiscoveryForAttach({ 185 | String? appId, 186 | String? fuchsiaModule, 187 | int? filterDevicePort, 188 | int? expectedHostPort, 189 | required bool ipv6, 190 | required fl.Logger logger, 191 | }) { 192 | if (getVMServiceDiscoveryForAttachFn != null) { 193 | return getVMServiceDiscoveryForAttachFn!( 194 | appId: appId, 195 | fuchsiaModule: fuchsiaModule, 196 | filterDevicePort: filterDevicePort, 197 | expectedHostPort: expectedHostPort, 198 | ipv6: ipv6, 199 | logger: logger, 200 | ); 201 | } 202 | fail('Should not access getVMServiceDiscoveryForAttach'); 203 | } 204 | 205 | @override 206 | Future installApp(fl.ApplicationPackage app, {String? userIdentifier}) { 207 | if (installAppFn != null) { 208 | return installAppFn!(app, userIdentifier: userIdentifier); 209 | } 210 | fail('Should not access installApp'); 211 | } 212 | 213 | @override 214 | Future isAppInstalled( 215 | fl.ApplicationPackage app, { 216 | String? userIdentifier, 217 | }) { 218 | if (isAppInstalledFn != null) { 219 | return isAppInstalledFn!(app, userIdentifier: userIdentifier); 220 | } 221 | fail('Should not access isAppInstalled'); 222 | } 223 | 224 | @override 225 | Future isLatestBuildInstalled(fl.ApplicationPackage app) { 226 | if (isLatestBuildInstalledFn != null) return isLatestBuildInstalledFn!(app); 227 | fail('Should not access isLatestBuildInstalled'); 228 | } 229 | 230 | @override 231 | Future isSupported() async { 232 | if (isSupportedFn != null) return isSupportedFn!(); 233 | fail('Should not access isSupported'); 234 | } 235 | 236 | @override 237 | bool isSupportedForProject(fl.FlutterProject flutterProject) { 238 | if (isSupportedForProjectFn != null) { 239 | return isSupportedForProjectFn!(flutterProject); 240 | } 241 | fail('Should not access isSupportedForProject'); 242 | } 243 | 244 | @override 245 | Future queryMemoryInfo() { 246 | if (queryMemoryInfoFn != null) return queryMemoryInfoFn!(); 247 | fail('Should not access queryMemoryInfo'); 248 | } 249 | 250 | @override 251 | Future startApp( 252 | covariant fl.ApplicationPackage? package, { 253 | String? mainPath, 254 | String? route, 255 | required fl.DebuggingOptions debuggingOptions, 256 | Map platformArgs = const {}, 257 | bool prebuiltApplication = false, 258 | String? userIdentifier, 259 | }) { 260 | if (startAppFn != null) { 261 | return startAppFn!( 262 | package, 263 | mainPath: mainPath, 264 | route: route, 265 | debuggingOptions: debuggingOptions, 266 | platformArgs: platformArgs, 267 | prebuiltApplication: prebuiltApplication, 268 | userIdentifier: userIdentifier, 269 | ); 270 | } 271 | fail('Should not access startApp'); 272 | } 273 | 274 | @override 275 | Future stopApp(fl.ApplicationPackage? app, {String? userIdentifier}) { 276 | if (stopAppFn != null) { 277 | return stopAppFn!(app, userIdentifier: userIdentifier); 278 | } 279 | fail('Should not access stopApp'); 280 | } 281 | 282 | @override 283 | Future supportMessage() { 284 | if (supportMessageFn != null) return supportMessageFn!(); 285 | fail('Should not access supportMessage'); 286 | } 287 | 288 | @override 289 | Future takeScreenshot(fl.File outputFile) { 290 | if (takeScreenshotFn != null) return takeScreenshotFn!(outputFile); 291 | fail('Should not access takeScreenshot'); 292 | } 293 | 294 | @override 295 | FutureOr supportsRuntimeMode(fl.BuildMode buildMode) { 296 | if (supportsRuntimeModeFn != null) return supportsRuntimeModeFn!(buildMode); 297 | fail('Should not access supportsRuntimeMode'); 298 | } 299 | 300 | @override 301 | Future> toJson() { 302 | if (toJsonFn != null) return toJsonFn!(); 303 | fail('Should not access toJson'); 304 | } 305 | 306 | @override 307 | Future uninstallApp( 308 | fl.ApplicationPackage app, { 309 | String? userIdentifier, 310 | }) { 311 | if (uninstallAppFn != null) { 312 | return uninstallAppFn!(app, userIdentifier: userIdentifier); 313 | } 314 | fail('Should not access uninstallApp'); 315 | } 316 | 317 | @override 318 | Uri? devToolsUri; 319 | } 320 | -------------------------------------------------------------------------------- /lib/src/more_os_utils.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutterpi_tool/src/archive.dart'; 2 | import 'package:meta/meta.dart'; 3 | import 'package:process/process.dart'; 4 | 5 | import 'package:flutterpi_tool/src/fltool/common.dart'; 6 | import 'package:flutterpi_tool/src/common.dart'; 7 | 8 | enum ArchiveType { 9 | tarXz, 10 | tarGz, 11 | tar, 12 | zip, 13 | } 14 | 15 | abstract class MoreOperatingSystemUtils implements OperatingSystemUtils { 16 | factory MoreOperatingSystemUtils({ 17 | required FileSystem fileSystem, 18 | required Logger logger, 19 | required Platform platform, 20 | required ProcessManager processManager, 21 | }) { 22 | final os = OperatingSystemUtils( 23 | fileSystem: fileSystem, 24 | logger: logger, 25 | platform: platform, 26 | processManager: processManager, 27 | ); 28 | 29 | var moreOs = MoreOperatingSystemUtils.wrap(os); 30 | 31 | final processUtils = ProcessUtils( 32 | processManager: processManager, 33 | logger: logger, 34 | ); 35 | 36 | if (platform.isMacOS) { 37 | moreOs = MacosMoreOsUtils( 38 | delegate: moreOs, 39 | processUtils: processUtils, 40 | ); 41 | } else if (platform.isLinux) { 42 | moreOs = LinuxMoreOsUtils( 43 | delegate: moreOs, 44 | processUtils: processUtils, 45 | logger: logger, 46 | ); 47 | } 48 | 49 | return moreOs; 50 | } 51 | 52 | factory MoreOperatingSystemUtils.wrap(OperatingSystemUtils os) => 53 | MoreOperatingSystemUtilsWrapper(os: os); 54 | 55 | FlutterpiHostPlatform get fpiHostPlatform; 56 | 57 | @override 58 | void unpack( 59 | File gzippedTarFile, 60 | Directory targetDirectory, { 61 | ArchiveType? type, 62 | Archive Function(File)? decoder, 63 | }); 64 | } 65 | 66 | class MoreOperatingSystemUtilsWrapper implements MoreOperatingSystemUtils { 67 | MoreOperatingSystemUtilsWrapper({ 68 | required this.os, 69 | }); 70 | 71 | final OperatingSystemUtils os; 72 | 73 | @override 74 | void chmod(FileSystemEntity entity, String mode) { 75 | return os.chmod(entity, mode); 76 | } 77 | 78 | @override 79 | HostPlatform get hostPlatform => os.hostPlatform; 80 | 81 | @override 82 | FlutterpiHostPlatform get fpiHostPlatform { 83 | return switch (hostPlatform) { 84 | HostPlatform.darwin_x64 => FlutterpiHostPlatform.darwinX64, 85 | HostPlatform.darwin_arm64 => FlutterpiHostPlatform.darwinARM64, 86 | HostPlatform.linux_x64 => FlutterpiHostPlatform.linuxX64, 87 | HostPlatform.linux_arm64 => FlutterpiHostPlatform.linuxARM64, 88 | HostPlatform.windows_x64 => FlutterpiHostPlatform.windowsX64, 89 | HostPlatform.windows_arm64 => FlutterpiHostPlatform.windowsARM64, 90 | }; 91 | } 92 | 93 | @override 94 | void makeExecutable(File file) { 95 | return os.makeExecutable(file); 96 | } 97 | 98 | @override 99 | File makePipe(String path) => os.makePipe(path); 100 | 101 | @override 102 | String get pathVarSeparator => os.pathVarSeparator; 103 | 104 | @override 105 | void unpack( 106 | File gzippedTarFile, 107 | Directory targetDirectory, { 108 | Archive Function(File)? decoder, 109 | ArchiveType? type, 110 | }) { 111 | if (decoder == null && type == null || type == ArchiveType.tarGz) { 112 | return os.unpack(gzippedTarFile, targetDirectory); 113 | } else { 114 | decoder ??= switch (type) { 115 | ArchiveType.tarXz => (file) => TarDecoder() 116 | .decodeBytes(XZDecoder().decodeBytes(file.readAsBytesSync())), 117 | ArchiveType.tarGz => (file) => TarDecoder() 118 | .decodeBytes(GZipDecoder().decodeBytes(file.readAsBytesSync())), 119 | ArchiveType.tar => (file) => 120 | TarDecoder().decodeBytes(file.readAsBytesSync()), 121 | ArchiveType.zip => (file) => 122 | ZipDecoder().decodeBytes(file.readAsBytesSync()), 123 | null => throw 'unreachable', 124 | }; 125 | 126 | final archive = decoder(gzippedTarFile); 127 | 128 | _unpackArchive(archive, targetDirectory); 129 | } 130 | } 131 | 132 | void _unpackArchive(Archive archive, Directory targetDirectory) { 133 | final fs = targetDirectory.fileSystem; 134 | 135 | for (final archiveFile in archive.files) { 136 | if (!archiveFile.isFile || archiveFile.name.endsWith('/')) { 137 | continue; 138 | } 139 | 140 | final destFile = fs.file( 141 | fs.path.canonicalize( 142 | fs.path.join( 143 | targetDirectory.path, 144 | archiveFile.name, 145 | ), 146 | ), 147 | ); 148 | 149 | // Validate that the destFile is within the targetDirectory we want to 150 | // extract to. 151 | // 152 | // See https://snyk.io/research/zip-slip-vulnerability for more context. 153 | final destinationFileCanonicalPath = fs.path.canonicalize(destFile.path); 154 | final targetDirectoryCanonicalPath = 155 | fs.path.canonicalize(targetDirectory.path); 156 | 157 | if (!destinationFileCanonicalPath 158 | .startsWith(targetDirectoryCanonicalPath)) { 159 | throw StateError( 160 | 'Tried to extract the file $destinationFileCanonicalPath outside of the ' 161 | 'target directory $targetDirectoryCanonicalPath', 162 | ); 163 | } 164 | 165 | if (!destFile.parent.existsSync()) { 166 | destFile.parent.createSync(recursive: true); 167 | } 168 | 169 | destFile.writeAsBytesSync(archiveFile.content as List); 170 | } 171 | } 172 | 173 | @override 174 | void unzip(File file, Directory targetDirectory) { 175 | return os.unzip(file, targetDirectory); 176 | } 177 | 178 | @override 179 | Future findFreePort({bool ipv6 = false}) { 180 | return os.findFreePort(ipv6: ipv6); 181 | } 182 | 183 | @override 184 | int? getDirectorySize(Directory directory) { 185 | return os.getDirectorySize(directory); 186 | } 187 | 188 | @override 189 | Stream> gzipLevel1Stream(Stream> stream) { 190 | return os.gzipLevel1Stream(stream); 191 | } 192 | 193 | @override 194 | String get name => os.name; 195 | 196 | @override 197 | File? which(String execName) { 198 | return os.which(execName); 199 | } 200 | 201 | @override 202 | List whichAll(String execName) { 203 | return os.whichAll(execName); 204 | } 205 | } 206 | 207 | class DelegatingMoreOsUtils implements MoreOperatingSystemUtils { 208 | DelegatingMoreOsUtils({ 209 | required this.delegate, 210 | }); 211 | 212 | @protected 213 | final MoreOperatingSystemUtils delegate; 214 | 215 | @override 216 | void chmod(FileSystemEntity entity, String mode) { 217 | return delegate.chmod(entity, mode); 218 | } 219 | 220 | @override 221 | Future findFreePort({bool ipv6 = false}) { 222 | return delegate.findFreePort(ipv6: ipv6); 223 | } 224 | 225 | @override 226 | Stream> gzipLevel1Stream(Stream> stream) { 227 | return delegate.gzipLevel1Stream(stream); 228 | } 229 | 230 | @override 231 | HostPlatform get hostPlatform => delegate.hostPlatform; 232 | 233 | @override 234 | FlutterpiHostPlatform get fpiHostPlatform => delegate.fpiHostPlatform; 235 | 236 | @override 237 | void makeExecutable(File file) => delegate.makeExecutable(file); 238 | 239 | @override 240 | File makePipe(String path) => delegate.makePipe(path); 241 | 242 | @override 243 | String get name => delegate.name; 244 | 245 | @override 246 | String get pathVarSeparator => delegate.pathVarSeparator; 247 | 248 | @override 249 | void unpack( 250 | File gzippedTarFile, 251 | Directory targetDirectory, { 252 | ArchiveType? type, 253 | Archive Function(File)? decoder, 254 | }) { 255 | return delegate.unpack( 256 | gzippedTarFile, 257 | targetDirectory, 258 | decoder: decoder, 259 | type: type, 260 | ); 261 | } 262 | 263 | @override 264 | void unzip(File file, Directory targetDirectory) { 265 | return delegate.unzip(file, targetDirectory); 266 | } 267 | 268 | @override 269 | File? which(String execName) { 270 | return delegate.which(execName); 271 | } 272 | 273 | @override 274 | List whichAll(String execName) { 275 | return delegate.whichAll(execName); 276 | } 277 | 278 | @override 279 | int? getDirectorySize(Directory directory) { 280 | return delegate.getDirectorySize(directory); 281 | } 282 | } 283 | 284 | class PosixMoreOsUtils extends DelegatingMoreOsUtils { 285 | PosixMoreOsUtils({ 286 | required super.delegate, 287 | required this.processUtils, 288 | }); 289 | 290 | @protected 291 | final ProcessUtils processUtils; 292 | 293 | @override 294 | void unpack( 295 | File gzippedTarFile, 296 | Directory targetDirectory, { 297 | ArchiveType? type, 298 | Archive Function(File)? decoder, 299 | }) { 300 | if (decoder != null) { 301 | return delegate.unpack( 302 | gzippedTarFile, 303 | targetDirectory, 304 | decoder: decoder, 305 | type: type, 306 | ); 307 | } 308 | 309 | switch (type) { 310 | case ArchiveType.tarGz: 311 | case ArchiveType.tarXz: 312 | case ArchiveType.tar: 313 | final formatArg = switch (type) { 314 | ArchiveType.tarGz => 'z', 315 | ArchiveType.tarXz => 'J', 316 | ArchiveType.tar => '', 317 | _ => throw 'unreachable', 318 | }; 319 | 320 | processUtils.runSync( 321 | [ 322 | 'tar', 323 | '-x${formatArg}f', 324 | gzippedTarFile.path, 325 | '-C', 326 | targetDirectory.path, 327 | ], 328 | throwOnError: true, 329 | ); 330 | break; 331 | 332 | case ArchiveType.zip: 333 | unzip(gzippedTarFile, targetDirectory); 334 | 335 | case null: 336 | super.unpack(gzippedTarFile, targetDirectory); 337 | } 338 | } 339 | } 340 | 341 | class LinuxMoreOsUtils extends PosixMoreOsUtils { 342 | LinuxMoreOsUtils({ 343 | required super.delegate, 344 | required super.processUtils, 345 | required this.logger, 346 | }); 347 | 348 | @protected 349 | final Logger logger; 350 | 351 | FlutterpiHostPlatform _findHostPlatform() { 352 | final result = processUtils.runSync(['uname', '-m']); 353 | // On x64 stdout is "uname -m: x86_64" 354 | // On arm64 stdout is "uname -m: aarch64, arm64_v8a" 355 | if (result.exitCode != 0) { 356 | logger.printError( 357 | 'Encountered an error trying to run "uname -m":\n' 358 | ' exit code: ${result.exitCode}\n' 359 | ' stdout: ${result.stdout.trimRight()}\n' 360 | ' stderr: ${result.stderr.trimRight()}\n' 361 | 'Assuming host platform is ${FlutterpiHostPlatform.linuxX64}.', 362 | ); 363 | return FlutterpiHostPlatform.linuxX64; 364 | } 365 | 366 | final machine = result.stdout.trim(); 367 | 368 | if (machine.endsWith('x86_64')) { 369 | return FlutterpiHostPlatform.linuxX64; 370 | } else if (machine == 'aarch64' || machine == 'arm64') { 371 | return FlutterpiHostPlatform.linuxARM64; 372 | } else if (machine == 'armv7l' || machine == 'arm') { 373 | return FlutterpiHostPlatform.linuxARM; 374 | } else if (machine == 'riscv64') { 375 | return FlutterpiHostPlatform.linuxRV64; 376 | } else { 377 | logger.printError( 378 | 'Unrecognized host platform: uname -m: $machine\n' 379 | 'Assuming host platform is ${FlutterpiHostPlatform.linuxX64}.', 380 | ); 381 | return FlutterpiHostPlatform.linuxX64; 382 | } 383 | } 384 | 385 | @override 386 | late final FlutterpiHostPlatform fpiHostPlatform = _findHostPlatform(); 387 | } 388 | 389 | class MacosMoreOsUtils extends PosixMoreOsUtils { 390 | MacosMoreOsUtils({ 391 | required super.delegate, 392 | required super.processUtils, 393 | }); 394 | } 395 | -------------------------------------------------------------------------------- /lib/src/cli/flutterpi_command.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import 'package:args/command_runner.dart'; 4 | import 'package:file/file.dart'; 5 | import 'package:flutterpi_tool/src/cache.dart'; 6 | import 'package:flutterpi_tool/src/common.dart'; 7 | import 'package:flutterpi_tool/src/devices/flutterpi_ssh/device.dart'; 8 | import 'package:flutterpi_tool/src/fltool/common.dart' as fl; 9 | import 'package:flutterpi_tool/src/fltool/globals.dart' as globals; 10 | import 'package:flutterpi_tool/src/github.dart'; 11 | import 'package:flutterpi_tool/src/more_os_utils.dart'; 12 | import 'package:github/github.dart' as gh; 13 | import 'package:http/http.dart' as http; 14 | import 'package:process/process.dart'; 15 | 16 | enum FilesystemLayout { 17 | flutterPi, 18 | metaFlutter; 19 | 20 | @override 21 | String toString() { 22 | return switch (this) { 23 | flutterPi => 'flutter-pi', 24 | metaFlutter => 'meta-flutter' 25 | }; 26 | } 27 | 28 | static FilesystemLayout fromString(String string) { 29 | return switch (string) { 30 | 'flutter-pi' => FilesystemLayout.flutterPi, 31 | 'meta-flutter' => FilesystemLayout.metaFlutter, 32 | _ => throw ArgumentError.value(string, 'Unknown filesystem layout'), 33 | }; 34 | } 35 | } 36 | 37 | mixin FlutterpiCommandMixin on fl.FlutterCommand { 38 | MyGithub createGithub({http.Client? httpClient}) { 39 | httpClient ??= http.Client(); 40 | 41 | final String? token; 42 | if (globals.platform.environment['GITHUB_TOKEN'] case final envToken?) { 43 | globals.logger.printTrace('Using GITHUB_TOKEN from environment.'); 44 | token = envToken; 45 | } else if (argParser.options.containsKey('github-artifacts-auth-token')) { 46 | token = stringArg('github-artifacts-auth-token'); 47 | } else { 48 | token = null; 49 | } 50 | 51 | return MyGithub.caching( 52 | httpClient: httpClient, 53 | auth: token != null ? gh.Authentication.bearerToken(token) : null, 54 | ); 55 | } 56 | 57 | FlutterpiCache createCustomCache({ 58 | required FileSystem fs, 59 | required fl.ShutdownHooks shutdownHooks, 60 | required fl.Logger logger, 61 | required fl.Platform platform, 62 | required MoreOperatingSystemUtils os, 63 | required fl.FlutterProjectFactory projectFactory, 64 | required ProcessManager processManager, 65 | http.Client? httpClient, 66 | }) { 67 | final repo = stringArg('github-artifacts-repo'); 68 | final runId = stringArg('github-artifacts-runid'); 69 | final githubEngineHash = stringArg('github-artifacts-engine-version'); 70 | 71 | if (runId != null) { 72 | return FlutterpiCache.fromWorkflow( 73 | hooks: shutdownHooks, 74 | logger: logger, 75 | fileSystem: fs, 76 | platform: platform, 77 | osUtils: os, 78 | projectFactory: projectFactory, 79 | processManager: processManager, 80 | repo: repo != null ? gh.RepositorySlug.full(repo) : null, 81 | runId: runId, 82 | availableEngineVersion: githubEngineHash, 83 | github: createGithub(httpClient: httpClient), 84 | ); 85 | } else { 86 | return FlutterpiCache( 87 | hooks: shutdownHooks, 88 | logger: logger, 89 | fileSystem: fs, 90 | platform: platform, 91 | osUtils: os, 92 | projectFactory: projectFactory, 93 | processManager: processManager, 94 | repo: repo != null ? gh.RepositorySlug.full(repo) : null, 95 | github: createGithub(httpClient: httpClient), 96 | ); 97 | } 98 | } 99 | 100 | void usesSshRemoteNonOptionArg({bool mandatory = true}) { 101 | assert(mandatory); 102 | } 103 | 104 | void usesDisplaySizeArg() { 105 | argParser.addOption( 106 | 'display-size', 107 | help: 108 | 'The physical size of the device display in millimeters. This is used to calculate the device pixel ratio.', 109 | valueHelp: 'width x height', 110 | ); 111 | } 112 | 113 | void usesDummyDisplayArg() { 114 | argParser.addFlag( 115 | 'dummy-display', 116 | help: 117 | 'Simulate a dummy display. (Useful if no real display is connected)', 118 | ); 119 | 120 | argParser.addOption( 121 | 'dummy-display-size', 122 | help: 123 | 'Simulate a dummy display with a specific size in physical pixels. (Useful if no real display is connected)', 124 | valueHelp: 'width x height', 125 | ); 126 | } 127 | 128 | void usesLocalFlutterpiExecutableArg({bool verboseHelp = false}) { 129 | argParser.addOption( 130 | 'flutterpi-binary', 131 | help: 132 | 'Use a custom, pre-built flutter-pi executable instead of download one from the Flutter-Pi CI.', 133 | valueHelp: 'path', 134 | hide: !verboseHelp, 135 | ); 136 | } 137 | 138 | (int, int)? get displaySize { 139 | final size = stringArg('display-size'); 140 | if (size == null) { 141 | return null; 142 | } 143 | 144 | final parts = size.split('x'); 145 | if (parts.length != 2) { 146 | usageException( 147 | 'Invalid --display-size: Expected two dimensions separated by "x".', 148 | ); 149 | } 150 | 151 | try { 152 | return (int.parse(parts[0].trim()), int.parse(parts[1].trim())); 153 | } on FormatException { 154 | usageException( 155 | 'Invalid --display-size: Expected both dimensions to be integers.', 156 | ); 157 | } 158 | } 159 | 160 | (int, int)? get dummyDisplaySize { 161 | final size = stringArg('dummy-display-size'); 162 | if (size == null) { 163 | return null; 164 | } 165 | 166 | final parts = size.split('x'); 167 | if (parts.length != 2) { 168 | usageException( 169 | 'Invalid --dummy-display-size: Expected two dimensions separated by "x".', 170 | ); 171 | } 172 | 173 | try { 174 | return (int.parse(parts[0].trim()), int.parse(parts[1].trim())); 175 | } on FormatException { 176 | usageException( 177 | 'Invalid --dummy-display-size: Expected both dimensions to be integers.', 178 | ); 179 | } 180 | } 181 | 182 | bool get useDummyDisplay { 183 | final dummyDisplay = boolArg('dummy-display'); 184 | final dummyDisplaySize = stringArg('dummy-display-size'); 185 | if (dummyDisplay || dummyDisplaySize != null) { 186 | return true; 187 | } 188 | 189 | return false; 190 | } 191 | 192 | double? get pixelRatio { 193 | final ratio = stringArg('pixel-ratio'); 194 | if (ratio == null) { 195 | return null; 196 | } 197 | 198 | try { 199 | return double.parse(ratio); 200 | } on FormatException { 201 | usageException( 202 | 'Invalid --pixel-ratio: Expected a floating point number.', 203 | ); 204 | } 205 | } 206 | 207 | String get sshRemote { 208 | switch (argResults!.rest) { 209 | case [String id]: 210 | return id; 211 | case [String _, ...]: 212 | throw UsageException( 213 | 'Too many non-option arguments specified: ${argResults!.rest.skip(1)}', 214 | usage, 215 | ); 216 | case []: 217 | throw UsageException('Expected device id as non-option arg.', usage); 218 | default: 219 | throw StateError( 220 | 'Unexpected non-option argument list: ${argResults!.rest}', 221 | ); 222 | } 223 | } 224 | 225 | String get sshHostname { 226 | final remote = sshRemote; 227 | return remote.contains('@') ? remote.split('@').last : remote; 228 | } 229 | 230 | String? get sshUser { 231 | final remote = sshRemote; 232 | return remote.contains('@') ? remote.split('@').first : null; 233 | } 234 | 235 | final _contextOverrides = {}; 236 | 237 | void addContextOverride(dynamic Function() fn) { 238 | _contextOverrides[T] = fn; 239 | } 240 | 241 | void usesEngineFlavorOption() { 242 | argParser.addFlag( 243 | 'debug', 244 | help: 'Build for debug mode.', 245 | negatable: false, 246 | ); 247 | 248 | argParser.addFlag( 249 | 'profile', 250 | help: 'Build for profile mode.', 251 | negatable: false, 252 | ); 253 | 254 | argParser.addFlag( 255 | 'release', 256 | help: 'Build for release mode.', 257 | negatable: false, 258 | ); 259 | 260 | argParser.addFlag( 261 | 'debug-unoptimized', 262 | help: 263 | 'Build for debug mode and use unoptimized engine. (For stepping through engine code)', 264 | negatable: false, 265 | ); 266 | } 267 | 268 | void usesDebugSymbolsOption() { 269 | argParser.addFlag( 270 | 'debug-symbols', 271 | help: 'Include debug symbols in the output.', 272 | negatable: false, 273 | ); 274 | } 275 | 276 | bool getIncludeDebugSymbols() { 277 | return boolArg('debug-symbols'); 278 | } 279 | 280 | EngineFlavor getEngineFlavor() { 281 | // If we don't have any of the engine flavor options, default to debug. 282 | if (!argParser.options.containsKey('debug') && 283 | !argParser.options.containsKey('profile') && 284 | !argParser.options.containsKey('release') && 285 | !argParser.options.containsKey('debug-unoptimized')) { 286 | return EngineFlavor.debug; 287 | } 288 | 289 | final debug = boolArg('debug'); 290 | final profile = boolArg('profile'); 291 | final release = boolArg('release'); 292 | final debugUnopt = boolArg('debug-unoptimized'); 293 | 294 | final flags = [debug, profile, release, debugUnopt]; 295 | if (flags.where((flag) => flag).length > 1) { 296 | throw UsageException( 297 | 'Only one of "--debug", "--profile", "--release", ' 298 | 'or "--debug-unoptimized" can be specified.', 299 | '', 300 | ); 301 | } 302 | 303 | if (debug) { 304 | return EngineFlavor.debug; 305 | } else if (profile) { 306 | return EngineFlavor.profile; 307 | } else if (release) { 308 | return EngineFlavor.release; 309 | } else if (debugUnopt) { 310 | return EngineFlavor.debugUnopt; 311 | } else { 312 | return EngineFlavor.debug; 313 | } 314 | } 315 | 316 | File? getLocalFlutterpiExecutable() { 317 | final path = stringArg('flutterpi-binary'); 318 | if (path == null) { 319 | return null; 320 | } 321 | 322 | if (!globals.fs.isFileSync(path)) { 323 | usageException( 324 | 'The specified flutter-pi binary does not exist, ' 325 | 'or is not a file: $path', 326 | ); 327 | } 328 | 329 | return globals.fs.file(path); 330 | } 331 | 332 | void usesFilesystemLayoutArg({bool verboseHelp = false}) { 333 | argParser.addOption( 334 | 'fs-layout', 335 | valueHelp: 'layout', 336 | help: 337 | 'The filesystem layout of the built app bundle. Yocto (meta-flutter) ' 338 | 'uses a different filesystem layout for apps than flutter-pi normally ' 339 | 'accepts, so when trying to use flutterpi_tool with a device running ' 340 | 'a meta-flutter yocto image, the meta-flutter fs layout must be ' 341 | 'chosen instead.', 342 | allowed: ['flutter-pi', 'meta-flutter'], 343 | defaultsTo: 'flutter-pi', 344 | hide: !verboseHelp, 345 | ); 346 | } 347 | 348 | FilesystemLayout get filesystemLayout => switch (stringArg('fs-layout')) { 349 | 'flutter-pi' => FilesystemLayout.flutterPi, 350 | 'meta-flutter' => FilesystemLayout.metaFlutter, 351 | _ => usageException( 352 | 'Invalid --fs-layout: Expected "flutter-pi" or "meta-flutter".', 353 | ), 354 | }; 355 | 356 | Future> getDeviceBasedTargetPlatforms() async { 357 | final devices = await globals.deviceManager!.getDevices( 358 | filter: fl.DeviceDiscoveryFilter(excludeDisconnected: false), 359 | ); 360 | if (devices.isEmpty) { 361 | return {}; 362 | } 363 | 364 | final targetPlatforms = { 365 | for (final device in devices.whereType()) 366 | await device.flutterpiTargetPlatform, 367 | }; 368 | 369 | return targetPlatforms.expand((p) => [p, p.genericVariant]).toSet(); 370 | } 371 | 372 | Future populateCache({ 373 | FlutterpiHostPlatform? hostPlatform, 374 | Set? targetPlatforms, 375 | Set? flavors, 376 | Set? runtimeModes, 377 | bool? includeDebugSymbols, 378 | }) async { 379 | hostPlatform ??= 380 | switch ((globals.os as MoreOperatingSystemUtils).fpiHostPlatform) { 381 | FlutterpiHostPlatform.darwinARM64 => FlutterpiHostPlatform.darwinX64, 382 | FlutterpiHostPlatform.windowsARM64 => FlutterpiHostPlatform.windowsX64, 383 | FlutterpiHostPlatform other => other, 384 | }; 385 | 386 | targetPlatforms ??= await getDeviceBasedTargetPlatforms(); 387 | 388 | flavors ??= {getEngineFlavor()}; 389 | 390 | runtimeModes ??= {getEngineFlavor().buildMode}; 391 | 392 | includeDebugSymbols ??= getIncludeDebugSymbols(); 393 | 394 | await globals.flutterpiCache.updateAll( 395 | {fl.DevelopmentArtifact.universal}, 396 | host: hostPlatform, 397 | flutterpiPlatforms: targetPlatforms, 398 | runtimeModes: runtimeModes, 399 | engineFlavors: flavors, 400 | includeDebugSymbols: includeDebugSymbols, 401 | ); 402 | } 403 | 404 | @override 405 | void addBuildModeFlags({ 406 | required bool verboseHelp, 407 | bool defaultToRelease = true, 408 | bool excludeDebug = false, 409 | bool excludeRelease = false, 410 | }) { 411 | throw UnsupportedError( 412 | 'This method is not supported in Flutterpi commands.', 413 | ); 414 | } 415 | 416 | @override 417 | fl.BuildMode getBuildMode() { 418 | return getEngineFlavor().buildMode; 419 | } 420 | 421 | @override 422 | bool get usingCISystem => false; 423 | 424 | @override 425 | String? get debugLogsDirectoryPath => null; 426 | 427 | Future runWithContext(FutureOr Function() fn) async { 428 | return fl.context.run( 429 | body: fn, 430 | overrides: _contextOverrides, 431 | ); 432 | } 433 | 434 | @override 435 | Future runCommand(); 436 | 437 | @override 438 | Future run() async { 439 | return await runWithContext(() async { 440 | final specifiedDeviceId = stringArg( 441 | fl.FlutterGlobalOptions.kDeviceIdOption, 442 | global: true, 443 | ); 444 | if (specifiedDeviceId != null) { 445 | globals.deviceManager?.specifiedDeviceId = specifiedDeviceId; 446 | } 447 | 448 | await verifyThenRunCommand(null); 449 | }); 450 | } 451 | } 452 | -------------------------------------------------------------------------------- /lib/src/devices/flutterpi_ssh/ssh_utils.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | import 'package:flutterpi_tool/src/fltool/common.dart'; 3 | 4 | class SshException implements Exception { 5 | SshException(this.message); 6 | 7 | final String message; 8 | 9 | @override 10 | String toString() => message; 11 | } 12 | 13 | class SshUtils { 14 | SshUtils({ 15 | required this.processUtils, 16 | this.sshExecutable = 'ssh', 17 | this.scpExecutable = 'scp', 18 | required this.defaultRemote, 19 | }); 20 | 21 | final String sshExecutable; 22 | final String scpExecutable; 23 | final String defaultRemote; 24 | final ProcessUtils processUtils; 25 | 26 | List buildSshCommand({ 27 | bool? interactive = false, 28 | bool? allocateTTY, 29 | bool? exitOnForwardFailure, 30 | Iterable<(int, int)> remotePortForwards = const [], 31 | Iterable<(int, int)> localPortForwards = const [], 32 | Iterable extraArgs = const [], 33 | String? remote, 34 | String? command, 35 | }) { 36 | remote ??= defaultRemote; 37 | 38 | return [ 39 | sshExecutable, 40 | if (interactive != null) ...[ 41 | '-o', 42 | 'BatchMode=${interactive ? 'no' : 'yes'}', 43 | ], 44 | if (allocateTTY == true) '-tt', 45 | if (exitOnForwardFailure == true) ...[ 46 | '-o', 47 | 'ExitOnForwardFailure=yes', 48 | ] else if (exitOnForwardFailure == false) ...[ 49 | '-o', 50 | 'ExitOnForwardFailure=no', 51 | ], 52 | for (final (local, remote) in localPortForwards) ...[ 53 | '-L', 54 | '$local:localhost:$remote', 55 | ], 56 | for (final (remote, local) in remotePortForwards) ...[ 57 | '-R', 58 | '$local:localhost:$remote', 59 | ], 60 | if (command == null) '-T', 61 | ...extraArgs, 62 | remote, 63 | if (command != null) command, 64 | ]; 65 | } 66 | 67 | List buildUsermodAddGroupsCommand(Iterable groups) { 68 | if (groups.isEmpty) { 69 | throw ArgumentError.value(groups, 'groups', 'Groups must not be empty.'); 70 | } 71 | 72 | return ['usermod', '-aG', groups.join(','), r'$USER']; 73 | } 74 | 75 | Future runSsh({ 76 | String? remote, 77 | String? command, 78 | Iterable extraArgs = const [], 79 | bool throwOnError = false, 80 | String? workingDirectory, 81 | Map? environment, 82 | Duration? timeout, 83 | int timeoutRetries = 0, 84 | bool? allocateTTY, 85 | Iterable<(int, int)> localPortForwards = const [], 86 | Iterable<(int, int)> remotePortForwards = const [], 87 | bool? exitOnForwardFailure, 88 | }) { 89 | remote ??= defaultRemote; 90 | 91 | final cmd = buildSshCommand( 92 | allocateTTY: allocateTTY, 93 | exitOnForwardFailure: exitOnForwardFailure, 94 | localPortForwards: localPortForwards, 95 | extraArgs: extraArgs, 96 | remote: remote, 97 | command: command, 98 | ); 99 | 100 | try { 101 | return processUtils.run( 102 | cmd, 103 | throwOnError: throwOnError, 104 | workingDirectory: workingDirectory, 105 | environment: environment, 106 | timeout: timeout, 107 | timeoutRetries: timeoutRetries, 108 | ); 109 | } on ProcessException catch (e) { 110 | switch (e.errorCode) { 111 | case 255: 112 | throw SshException('SSH to "$remote" failed: $e'); 113 | default: 114 | throw SshException('Remote command failed: $e'); 115 | } 116 | } 117 | } 118 | 119 | Future startSsh({ 120 | String? remote, 121 | String? command, 122 | Iterable extraArgs = const [], 123 | String? workingDirectory, 124 | Map? environment, 125 | bool? allocateTTY, 126 | Iterable<(int, int)> remotePortForwards = const [], 127 | Iterable<(int, int)> localPortForwards = const [], 128 | bool? exitOnForwardFailure, 129 | ProcessStartMode mode = ProcessStartMode.normal, 130 | }) { 131 | remote ??= defaultRemote; 132 | 133 | final cmd = buildSshCommand( 134 | allocateTTY: allocateTTY, 135 | exitOnForwardFailure: exitOnForwardFailure, 136 | localPortForwards: localPortForwards, 137 | extraArgs: extraArgs, 138 | remote: remote, 139 | command: command, 140 | ); 141 | 142 | try { 143 | return processUtils.start( 144 | cmd, 145 | workingDirectory: workingDirectory, 146 | environment: environment, 147 | mode: mode, 148 | ); 149 | } on ProcessException catch (e) { 150 | switch (e.errorCode) { 151 | case 255: 152 | throw SshException('SSH to "$remote" failed: $e'); 153 | default: 154 | throw SshException('Remote command failed: $e'); 155 | } 156 | } 157 | } 158 | 159 | Future scp({ 160 | String? remote, 161 | required String localPath, 162 | required String remotePath, 163 | Iterable extraArgs = const [], 164 | bool throwOnError = false, 165 | String? workingDirectory, 166 | Map? environment, 167 | Duration? timeout, 168 | int timeoutRetries = 0, 169 | bool recursive = true, 170 | }) { 171 | remote ??= defaultRemote; 172 | 173 | try { 174 | return processUtils.run( 175 | [ 176 | scpExecutable, 177 | '-o', 178 | 'BatchMode=yes', 179 | if (recursive) '-r', 180 | ...extraArgs, 181 | localPath, 182 | '$remote:$remotePath', 183 | ], 184 | throwOnError: throwOnError, 185 | workingDirectory: workingDirectory, 186 | environment: environment, 187 | timeout: timeout, 188 | timeoutRetries: timeoutRetries, 189 | ); 190 | } on ProcessException catch (e) { 191 | switch (e.errorCode) { 192 | case 255: 193 | throw SshException('SSH to remote "$remote" failed: $e'); 194 | default: 195 | throw SshException('Remote command failed: $e'); 196 | } 197 | } 198 | } 199 | 200 | Future tryConnect({ 201 | Duration? timeout, 202 | bool throwOnError = false, 203 | String? remote, 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 | remote: remote, 214 | command: null, 215 | extraArgs: [ 216 | if (timeoutSecondsCeiled != null) ...[ 217 | '-o', 218 | 'ConnectTimeout=$timeoutSecondsCeiled', 219 | ], 220 | ], 221 | throwOnError: throwOnError, 222 | ); 223 | 224 | if (result.exitCode == 0) { 225 | return true; 226 | } else { 227 | return false; 228 | } 229 | } 230 | 231 | Future copy({ 232 | required String localPath, 233 | required String remotePath, 234 | String? remote, 235 | }) { 236 | return scp( 237 | localPath: localPath, 238 | remotePath: remotePath, 239 | remote: remote, 240 | throwOnError: true, 241 | recursive: true, 242 | ); 243 | } 244 | 245 | Future uname({ 246 | Iterable? args, 247 | Duration? timeout, 248 | String? remote, 249 | }) async { 250 | final command = ['uname', ...?args].join(' '); 251 | 252 | final result = await runSsh( 253 | command: command, 254 | throwOnError: true, 255 | timeout: timeout, 256 | remote: remote, 257 | ); 258 | 259 | return result.stdout.trim(); 260 | } 261 | 262 | Future id({ 263 | Iterable? args, 264 | Duration? timeout, 265 | String? remote, 266 | }) async { 267 | final command = ['id', ...?args].join(' '); 268 | 269 | final result = await runSsh( 270 | remote: remote, 271 | command: command, 272 | throwOnError: true, 273 | timeout: timeout, 274 | ); 275 | 276 | return result.stdout.trim(); 277 | } 278 | 279 | Future makeExecutable({ 280 | Iterable? args, 281 | Duration? timeout, 282 | String? remote, 283 | }) async { 284 | final command = ['chmod', '+x', ...?args].join(' '); 285 | 286 | await runSsh( 287 | remote: remote, 288 | command: command, 289 | throwOnError: true, 290 | timeout: timeout, 291 | ); 292 | } 293 | 294 | Future remoteUserBelongsToGroups( 295 | Iterable groups, { 296 | String? remote, 297 | }) async { 298 | final result = await id(args: ['-nG'], remote: remote); 299 | final userGroups = result.split(' '); 300 | return groups.every(userGroups.contains); 301 | } 302 | } 303 | 304 | class RemoteSpecificSshUtils implements SshUtils { 305 | RemoteSpecificSshUtils({ 306 | required this.inner, 307 | required this.remote, 308 | }); 309 | 310 | final SshUtils inner; 311 | final String remote; 312 | 313 | @override 314 | String get sshExecutable => inner.sshExecutable; 315 | 316 | @override 317 | String get scpExecutable => inner.scpExecutable; 318 | 319 | @override 320 | String get defaultRemote => remote; 321 | 322 | @override 323 | ProcessUtils get processUtils => inner.processUtils; 324 | 325 | @override 326 | List buildSshCommand({ 327 | bool? interactive = false, 328 | bool? allocateTTY, 329 | bool? exitOnForwardFailure, 330 | Iterable<(int, int)> remotePortForwards = const [], 331 | Iterable<(int, int)> localPortForwards = const [], 332 | Iterable extraArgs = const [], 333 | String? remote, 334 | String? command, 335 | }) { 336 | return inner.buildSshCommand( 337 | interactive: interactive, 338 | allocateTTY: allocateTTY, 339 | exitOnForwardFailure: exitOnForwardFailure, 340 | remotePortForwards: remotePortForwards, 341 | localPortForwards: localPortForwards, 342 | extraArgs: extraArgs, 343 | remote: remote ?? this.remote, 344 | command: command, 345 | ); 346 | } 347 | 348 | @override 349 | List buildUsermodAddGroupsCommand(Iterable groups) { 350 | return inner.buildUsermodAddGroupsCommand(groups); 351 | } 352 | 353 | @override 354 | Future runSsh({ 355 | String? remote, 356 | String? command, 357 | Iterable extraArgs = const [], 358 | bool throwOnError = false, 359 | String? workingDirectory, 360 | Map? environment, 361 | Duration? timeout, 362 | int timeoutRetries = 0, 363 | bool? allocateTTY, 364 | Iterable<(int, int)> localPortForwards = const [], 365 | Iterable<(int, int)> remotePortForwards = const [], 366 | bool? exitOnForwardFailure, 367 | }) { 368 | return inner.runSsh( 369 | remote: remote ?? this.remote, 370 | command: command, 371 | extraArgs: extraArgs, 372 | throwOnError: throwOnError, 373 | workingDirectory: workingDirectory, 374 | environment: environment, 375 | timeout: timeout, 376 | timeoutRetries: timeoutRetries, 377 | allocateTTY: allocateTTY, 378 | localPortForwards: localPortForwards, 379 | remotePortForwards: remotePortForwards, 380 | exitOnForwardFailure: exitOnForwardFailure, 381 | ); 382 | } 383 | 384 | @override 385 | Future startSsh({ 386 | String? remote, 387 | String? command, 388 | Iterable extraArgs = const [], 389 | String? workingDirectory, 390 | Map? environment, 391 | bool? allocateTTY, 392 | Iterable<(int, int)> remotePortForwards = const [], 393 | Iterable<(int, int)> localPortForwards = const [], 394 | bool? exitOnForwardFailure, 395 | ProcessStartMode mode = ProcessStartMode.normal, 396 | }) { 397 | return inner.startSsh( 398 | remote: remote ?? this.remote, 399 | command: command, 400 | extraArgs: extraArgs, 401 | workingDirectory: workingDirectory, 402 | environment: environment, 403 | allocateTTY: allocateTTY, 404 | remotePortForwards: remotePortForwards, 405 | localPortForwards: localPortForwards, 406 | exitOnForwardFailure: exitOnForwardFailure, 407 | mode: mode, 408 | ); 409 | } 410 | 411 | @override 412 | Future scp({ 413 | String? remote, 414 | required String localPath, 415 | required String remotePath, 416 | Iterable extraArgs = const [], 417 | bool throwOnError = false, 418 | String? workingDirectory, 419 | Map? environment, 420 | Duration? timeout, 421 | int timeoutRetries = 0, 422 | bool recursive = true, 423 | }) { 424 | return inner.scp( 425 | remote: remote ?? this.remote, 426 | localPath: localPath, 427 | remotePath: remotePath, 428 | extraArgs: extraArgs, 429 | throwOnError: throwOnError, 430 | workingDirectory: workingDirectory, 431 | environment: environment, 432 | timeout: timeout, 433 | timeoutRetries: timeoutRetries, 434 | recursive: recursive, 435 | ); 436 | } 437 | 438 | @override 439 | Future tryConnect({ 440 | Duration? timeout, 441 | bool throwOnError = false, 442 | String? remote, 443 | }) { 444 | return inner.tryConnect( 445 | timeout: timeout, 446 | throwOnError: throwOnError, 447 | remote: remote ?? this.remote, 448 | ); 449 | } 450 | 451 | @override 452 | Future copy({ 453 | required String localPath, 454 | required String remotePath, 455 | String? remote, 456 | }) { 457 | return inner.copy( 458 | localPath: localPath, 459 | remotePath: remotePath, 460 | remote: remote ?? this.remote, 461 | ); 462 | } 463 | 464 | @override 465 | Future uname({ 466 | Iterable? args, 467 | Duration? timeout, 468 | String? remote, 469 | }) { 470 | return inner.uname( 471 | args: args, 472 | timeout: timeout, 473 | remote: remote ?? this.remote, 474 | ); 475 | } 476 | 477 | @override 478 | Future id({ 479 | Iterable? args, 480 | Duration? timeout, 481 | String? remote, 482 | }) { 483 | return inner.id( 484 | args: args, 485 | timeout: timeout, 486 | remote: remote ?? this.remote, 487 | ); 488 | } 489 | 490 | @override 491 | Future makeExecutable({ 492 | Iterable? args, 493 | Duration? timeout, 494 | String? remote, 495 | }) { 496 | return inner.makeExecutable( 497 | args: args, 498 | timeout: timeout, 499 | remote: remote ?? this.remote, 500 | ); 501 | } 502 | 503 | @override 504 | Future remoteUserBelongsToGroups( 505 | Iterable groups, { 506 | String? remote, 507 | }) { 508 | return inner.remoteUserBelongsToGroups( 509 | groups, 510 | remote: remote ?? this.remote, 511 | ); 512 | } 513 | } 514 | -------------------------------------------------------------------------------- /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 | --------------------------------------------------------------------------------