From 49fbaabfd44c576ddc9f43483b4fd4be45ec2690 Mon Sep 17 00:00:00 2001 From: Feodor Fitsner Date: Tue, 25 Nov 2025 17:20:32 -0800 Subject: [PATCH 01/22] Draft: flet debug CLI command --- .../packages/flet-cli/src/flet_cli/cli.py | 2 + .../flet-cli/src/flet_cli/commands/build.py | 2230 +---------------- .../flet-cli/src/flet_cli/commands/debug.py | 145 ++ .../src/flet_cli/commands/flutter_base.py | 2200 ++++++++++++++++ .../packages/flet/docs/cli/flet-debug.md | 5 + sdk/python/packages/flet/mkdocs.yml | 1 + 6 files changed, 2389 insertions(+), 2194 deletions(-) create mode 100644 sdk/python/packages/flet-cli/src/flet_cli/commands/debug.py create mode 100644 sdk/python/packages/flet-cli/src/flet_cli/commands/flutter_base.py create mode 100644 sdk/python/packages/flet/docs/cli/flet-debug.md diff --git a/sdk/python/packages/flet-cli/src/flet_cli/cli.py b/sdk/python/packages/flet-cli/src/flet_cli/cli.py index 65f3b0593..ffb4b925a 100644 --- a/sdk/python/packages/flet-cli/src/flet_cli/cli.py +++ b/sdk/python/packages/flet-cli/src/flet_cli/cli.py @@ -4,6 +4,7 @@ import flet.version import flet_cli.commands.build import flet_cli.commands.create +import flet_cli.commands.debug import flet_cli.commands.doctor # Adding the doctor command import flet_cli.commands.pack import flet_cli.commands.publish @@ -78,6 +79,7 @@ def get_parser() -> argparse.ArgumentParser: flet_cli.commands.create.Command.register_to(sp, "create") flet_cli.commands.run.Command.register_to(sp, "run") flet_cli.commands.build.Command.register_to(sp, "build") + flet_cli.commands.debug.Command.register_to(sp, "debug") flet_cli.commands.pack.Command.register_to(sp, "pack") flet_cli.commands.publish.Command.register_to(sp, "publish") flet_cli.commands.serve.Command.register_to(sp, "serve") diff --git a/sdk/python/packages/flet-cli/src/flet_cli/commands/build.py b/sdk/python/packages/flet-cli/src/flet_cli/commands/build.py index 575715fbe..4abb3bbdf 100644 --- a/sdk/python/packages/flet-cli/src/flet_cli/commands/build.py +++ b/sdk/python/packages/flet-cli/src/flet_cli/commands/build.py @@ -1,57 +1,13 @@ import argparse -import glob -import os -import platform -import re -import shutil -import sys -from pathlib import Path -from typing import Optional, cast -import yaml -from packaging import version -from packaging.requirements import Requirement -from rich.console import Console, Group +from rich.console import Group from rich.live import Live -from rich.panel import Panel from rich.progress import Progress -from rich.style import Style -from rich.table import Column, Table -from rich.theme import Theme -import flet.version -import flet_cli.utils.processes as processes -from flet.utils import cleanup_path, copy_tree, is_windows, slugify -from flet.utils.platform_utils import get_bool_env_var -from flet.version import update_version -from flet_cli.commands.base import BaseCommand -from flet_cli.utils.hash_stamp import HashStamp -from flet_cli.utils.merge import merge_dict -from flet_cli.utils.project_dependencies import ( - get_poetry_dependencies, - get_project_dependencies, -) -from flet_cli.utils.pyproject_toml import load_pyproject_toml +from flet_cli.commands.flutter_base import BaseFlutterCommand, console -PYODIDE_ROOT_URL = "https://cdn.jsdelivr.net/pyodide/v0.27.7/full" -DEFAULT_TEMPLATE_URL = "gh:flet-dev/flet-build-template" -MINIMAL_FLUTTER_VERSION = version.Version("3.38.2") - -no_rich_output = get_bool_env_var("FLET_CLI_NO_RICH_OUTPUT") - -error_style = Style(color="red", bold=True) -warning_style = Style(color="yellow", bold=True) -console = Console( - log_path=False, - theme=Theme({"log.message": "green bold"}), - force_terminal=not no_rich_output, -) -verbose1_style = Style(dim=True, bold=False) -verbose2_style = Style(color="bright_black", bold=False) - - -class Command(BaseCommand): +class Command(BaseFlutterCommand): """ Build a Flet Python app into a platform-specific executable or installable bundle. It supports building for desktop (macOS, Linux, Windows), web, @@ -62,177 +18,6 @@ class Command(BaseCommand): def __init__(self, parser: argparse.ArgumentParser) -> None: super().__init__(parser) - self.env = {} - self.pubspec_path = None - self.rel_out_dir = None - self.assets_path = None - self.package_platform = None - self.config_platform = None - self.target_platform = None - self.flutter_dependencies = {} - self.package_app_path = None - self.options = None - self.template_data = None - self.python_module_filename = None - self.out_dir = None - self.python_module_name = None - self.get_pyproject = None - self.python_app_path = None - self.emojis = {} - self.dart_exe = None - self.verbose = False - self.build_dir = None - self.flutter_dir: Optional[Path] = None - self.flutter_packages_dir = None - self.flutter_packages_temp_dir = None - self.flutter_exe = None - self.skip_flutter_doctor = get_bool_env_var("FLET_CLI_SKIP_FLUTTER_DOCTOR") - self.no_rich_output = no_rich_output - self.current_platform = platform.system() - self.platforms = { - "windows": { - "package_platform": "Windows", - "config_platform": "windows", - "flutter_build_command": "windows", - "status_text": "Windows app", - "outputs": ["build/windows/x64/runner/Release/*"], - "dist": "windows", - "can_be_run_on": ["Windows"], - }, - "macos": { - "package_platform": "Darwin", - "config_platform": "macos", - "flutter_build_command": "macos", - "status_text": "macOS bundle", - "outputs": ["build/macos/Build/Products/Release/{product_name}.app"], - "dist": "macos", - "can_be_run_on": ["Darwin"], - }, - "linux": { - "package_platform": "Linux", - "config_platform": "linux", - "flutter_build_command": "linux", - "status_text": "app for Linux", - "outputs": ["build/linux/{arch}/release/bundle/*"], - "dist": "linux", - "can_be_run_on": ["Linux"], - }, - "web": { - "package_platform": "Pyodide", - "config_platform": "web", - "flutter_build_command": "web", - "status_text": "web app", - "outputs": ["build/web/*"], - "dist": "web", - "can_be_run_on": ["Darwin", "Windows", "Linux"], - }, - "apk": { - "package_platform": "Android", - "config_platform": "android", - "flutter_build_command": "apk", - "status_text": ".apk for Android", - "outputs": ["build/app/outputs/flutter-apk/*"], - "dist": "apk", - "can_be_run_on": ["Darwin", "Windows", "Linux"], - }, - "aab": { - "package_platform": "Android", - "config_platform": "android", - "flutter_build_command": "appbundle", - "status_text": ".aab bundle for Android", - "outputs": ["build/app/outputs/bundle/release/*"], - "dist": "aab", - "can_be_run_on": ["Darwin", "Windows", "Linux"], - }, - "ipa": { - "package_platform": "iOS", - "config_platform": "ios", - "flutter_build_command": "ipa", - "status_text": ".ipa bundle for iOS", - "outputs": ["build/ios/archive/*", "build/ios/ipa/*"], - "dist": "ipa", - "can_be_run_on": ["Darwin"], - }, - } - - self.cross_platform_permissions = { - "location": { - "info_plist": { - "NSLocationWhenInUseUsageDescription": "This app uses location " - "service when in use.", - "NSLocationAlwaysAndWhenInUseUsageDescription": "This app uses " - "location service.", - }, - "macos_entitlements": { - "com.apple.security.personal-information.location": True - }, - "android_permissions": { - "android.permission.ACCESS_FINE_LOCATION": True, - "android.permission.ACCESS_COARSE_LOCATION": True, - "android.permission.ACCESS_BACKGROUND_LOCATION": True, - }, - "android_features": { - "android.hardware.location.network": False, - "android.hardware.location.gps": False, - }, - }, - "camera": { - "info_plist": { - "NSCameraUsageDescription": "This app uses the camera to capture " - "photos and videos." - }, - "macos_entitlements": {"com.apple.security.device.camera": True}, - "android_permissions": {"android.permission.CAMERA": True}, - "android_features": { - "android.hardware.camera": False, - "android.hardware.camera.any": False, - "android.hardware.camera.front": False, - "android.hardware.camera.external": False, - "android.hardware.camera.autofocus": False, - }, - }, - "microphone": { - "info_plist": { - "NSMicrophoneUsageDescription": "This app uses microphone to " - "record sounds.", - }, - "macos_entitlements": {"com.apple.security.device.audio-input": True}, - "android_permissions": { - "android.permission.RECORD_AUDIO": True, - "android.permission.WRITE_EXTERNAL_STORAGE": True, - "android.permission.READ_EXTERNAL_STORAGE": True, - }, - "android_features": {}, - }, - "photo_library": { - "info_plist": { - "NSPhotoLibraryUsageDescription": "This app saves photos and " - "videos to the photo library." - }, - "macos_entitlements": { - "com.apple.security.personal-information.photos-library": True - }, - "android_permissions": { - "android.permission.READ_MEDIA_VISUAL_USER_SELECTED": True - }, - "android_features": {}, - }, - } - - # create and display build-platform-matrix table - self.platform_matrix_table = Table( - Column("Command", style="cyan", justify="left"), - Column("Platform", style="magenta", justify="center"), - title="Build Platform Matrix", - header_style="bold", - show_lines=True, - ) - for p, info in self.platforms.items(): - self.platform_matrix_table.add_row( - "flet build " + p, - ", ".join(info["can_be_run_on"]).replace("Darwin", "macOS"), - ) - def add_arguments(self, parser: argparse.ArgumentParser) -> None: parser.add_argument( "target_platform", @@ -240,28 +25,6 @@ def add_arguments(self, parser: argparse.ArgumentParser) -> None: choices=["macos", "linux", "windows", "web", "apk", "aab", "ipa"], help="The target platform or type of package to build", ) - parser.add_argument( - "python_app_path", - type=str, - nargs="?", - default=".", - help="Path to a directory with a Flet Python program", - ) - parser.add_argument( - "--arch", - dest="target_arch", - nargs="+", - default=[], - help="Build for specific CPU architectures " - "(used in macOS and Android builds only). Example: `--arch arm64 x64`", - ) - parser.add_argument( - "--exclude", - dest="exclude", - nargs="+", - default=[], - help="Files and/or directories to exclude from the package", - ) parser.add_argument( "-o", "--output", @@ -270,380 +33,13 @@ def add_arguments(self, parser: argparse.ArgumentParser) -> None: help="Output directory for the final executable/bundle " "(default: /build/)", ) - parser.add_argument( - "--clear-cache", - dest="clear_cache", - action="store_true", - default=None, - help="Remove any existing build cache before starting the build process", - ) - parser.add_argument( - "--project", - dest="project_name", - required=False, - help="Project name for the executable/bundle. " - "It is used in metadata and bundle IDs", - ) - parser.add_argument( - "--description", - dest="description", - required=False, - help="Short description of the application", - ) - parser.add_argument( - "--product", - dest="product_name", - required=False, - help="Display name of the app that is shown in window titles " - "and about app dialogs", - ) - parser.add_argument( - "--org", - dest="org_name", - required=False, - help="Organization name in reverse domain name notation, " - "e.g. `com.mycompany`, combined with project name and " - "used in bundle IDs and signing", - ) - parser.add_argument( - "--bundle-id", - dest="bundle_id", - required=False, - help="Bundle ID for the application, e.g. `com.mycompany.app-name`. " - "It is used as an iOS, Android, macOS and Linux bundle ID", - ) - parser.add_argument( - "--company", - dest="company_name", - required=False, - help="Company name to display in about app dialogs", - ) - parser.add_argument( - "--copyright", - dest="copyright", - required=False, - help="Copyright text to display in about app dialogs", - ) - parser.add_argument( - "--android-adaptive-icon-background", - dest="android_adaptive_icon_background", - required=False, - help="The color to be used to fill out the background of " - "Android adaptive icons", - ) - parser.add_argument( - "--splash-color", - dest="splash_color", - required=False, - help="Background color of app splash screen on iOS, Android and web", - ) - parser.add_argument( - "--splash-dark-color", - dest="splash_dark_color", - required=False, - help="Background color in dark mode of app splash screen on " - "iOS, Android and web", - ) - parser.add_argument( - "--no-web-splash", - dest="no_web_splash", - action="store_true", - default=None, - help="Disable splash screen on web platform", - ) - parser.add_argument( - "--no-ios-splash", - dest="no_ios_splash", - action="store_true", - default=None, - help="Disable splash screen on iOS platform", - ) - parser.add_argument( - "--no-android-splash", - dest="no_android_splash", - action="store_true", - default=None, - help="Disable splash screen on Android platform", - ) - parser.add_argument( - "--ios-team-id", - dest="ios_team_id", - type=str, - help="Apple developer team ID for signing iOS app bundle (ipa only)", - required=False, - ) - parser.add_argument( - "--ios-export-method", - dest="ios_export_method", - type=str, - required=False, - help="Export method for iOS app bundle (default: debugging)", - ) - parser.add_argument( - "--ios-provisioning-profile", - dest="ios_provisioning_profile", - type=str, - required=False, - help="Provisioning profile name or UUID that should be used to sign and " - "export iOS app bundle", - ) - parser.add_argument( - "--ios-signing-certificate", - dest="ios_signing_certificate", - type=str, - required=False, - help="Signing certificate name, SHA-1 hash, or automatic selector to use " - "for signing iOS app bundle", - ) - parser.add_argument( - "--base-url", - dest="base_url", - type=str, - help="Base URL from which the app is served (web only)", - ) - parser.add_argument( - "--web-renderer", - dest="web_renderer", - choices=["auto", "canvaskit", "skwasm"], - help="Flutter web renderer to use (web only)", - ) - parser.add_argument( - "--route-url-strategy", - dest="route_url_strategy", - choices=["path", "hash"], - help="Base URL path to serve the app from. " - "Useful if the app is hosted in a subdirectory (web only)", - ) - parser.add_argument( - "--pwa-background-color", - dest="pwa_background_color", - required=False, - help="Initial background color for your web app (web only)", - ) - parser.add_argument( - "--pwa-theme-color", - dest="pwa_theme_color", - required=False, - help="Default color for your web app's user interface (web only)", - ) - parser.add_argument( - "--no-wasm", - dest="no_wasm", - action="store_true", - default=False, - help="Disable WASM target for web build (web only)", - ) - parser.add_argument( - "--no-cdn", - dest="no_cdn", - action="store_true", - default=False, - help="Disable loading of CanvasKit, Pyodide and fonts from CDN", - ) - parser.add_argument( - "--split-per-abi", - dest="split_per_abi", - action="store_true", - default=None, - help="Split the APKs per ABIs (Android only)", - ) - parser.add_argument( - "--compile-app", - dest="compile_app", - action="store_true", - default=None, - help="Pre-compile app's `.py` files to `.pyc`", - ) - parser.add_argument( - "--compile-packages", - dest="compile_packages", - action="store_true", - default=None, - help="Pre-compile site packages' `.py` files to `.pyc`", - ) - parser.add_argument( - "--cleanup-app", - dest="cleanup_app", - action="store_true", - default=None, - help="Remove unnecessary app files upon packaging", - ) - parser.add_argument( - "--cleanup-app-files", - dest="cleanup_app_files", - action="append", - nargs="*", - help="The list of globs to delete extra app files and directories", - ) - parser.add_argument( - "--cleanup-packages", - dest="cleanup_packages", - action="store_true", - default=None, - help="Remove unnecessary package files upon packaging", - ) - parser.add_argument( - "--cleanup-package-files", - dest="cleanup_package_files", - action="append", - nargs="*", - help="The list of globs to delete extra package files and directories", - ) - parser.add_argument( - "--flutter-build-args", - dest="flutter_build_args", - action="append", - nargs="*", - help="Additional arguments for flutter build command", - ) - parser.add_argument( - "--source-packages", - dest="source_packages", - nargs="+", - default=[], - help="The list of Python packages to install from source distributions", - ) - parser.add_argument( - "--info-plist", - dest="info_plist", - nargs="+", - default=[], - help="The list of `=|True|False` pairs to add to Info.plist " - "for macOS and iOS builds (macos and ipa only)", - ) - parser.add_argument( - "--macos-entitlements", - dest="macos_entitlements", - nargs="+", - default=[], - help="The list of `=|True|False` entitlements for " - "macOS builds (macos only)", - ) - parser.add_argument( - "--android-features", - dest="android_features", - nargs="+", - default=[], - help="The list of `=True|False` features to add to " - "AndroidManifest.xml for Android builds (android only)", - ) - parser.add_argument( - "--android-permissions", - dest="android_permissions", - nargs="+", - default=[], - help="The list of `=True|False` permissions to add to " - "AndroidManifest.xml for Android builds (android only)", - ) - parser.add_argument( - "--android-meta-data", - dest="android_meta_data", - nargs="+", - default=[], - help="The list of `=` app meta-data entries to add to " - "AndroidManifest.xml for Android builds (android only)", - ) - parser.add_argument( - "--permissions", - dest="permissions", - nargs="+", - default=[], - choices=["location", "camera", "microphone", "photo_library"], - help="The list of pre-defined cross-platform permissions for iOS, Android " - "and macOS builds", - ) - parser.add_argument( - "--deep-linking-scheme", - dest="deep_linking_scheme", - help="Deep linking URL scheme to configure for iOS and Android builds, " - "i.g. `https` or `myapp`", - ) - parser.add_argument( - "--deep-linking-host", - dest="deep_linking_host", - help="Deep linking URL host for iOS and Android builds", - ) - parser.add_argument( - "--android-signing-key-store", - dest="android_signing_key_store", - help="path to an upload keystore `.jks` file for Android apps", - ) - parser.add_argument( - "--android-signing-key-store-password", - dest="android_signing_key_store_password", - help="Android signing store password", - ) - parser.add_argument( - "--android-signing-key-password", - dest="android_signing_key_password", - help="Android signing key password", - ) - parser.add_argument( - "--android-signing-key-alias", - dest="android_signing_key_alias", - default="upload", - help="Android signing key alias (default: upload)", - ) - parser.add_argument( - "--build-number", - dest="build_number", - type=int, - help="Build number - an identifier used as an internal version number", - ) - parser.add_argument( - "--build-version", - dest="build_version", - help="Build version - a `x.y.z` string used as the version number " - "shown to users", - ) - parser.add_argument( - "--module-name", - dest="module_name", - help="Python module name with an app entry point", - ) - parser.add_argument( - "--template", - dest="template", - type=str, - help="Directory containing Flutter bootstrap template, or a URL " - "to a git repository template", - ) - parser.add_argument( - "--template-dir", - dest="template_dir", - type=str, - help="Relative path to a Flutter bootstrap template in a repository", - ) - parser.add_argument( - "--template-ref", - dest="template_ref", - type=str, - help="The branch, tag or commit ID to checkout after cloning " - "the repository with Flutter bootstrap template", - ) - parser.add_argument( - "--show-platform-matrix", - action="store_true", - default=False, - help="Display the build platform matrix in a table, then exit", - ) - parser.add_argument( - "--no-rich-output", - action="store_true", - default=False, - help="Disable rich output and prefer plain text. Useful on Windows builds", - ) - parser.add_argument( - "--skip-flutter-doctor", - action="store_true", - default=False, - help="Skip running Flutter doctor upon failed builds", - ) + super().add_arguments(parser) def handle(self, options: argparse.Namespace) -> None: - self.options = options + super().handle(options) + assert self.target_platform self.status = console.status( - f"[bold blue]Initializing {self.options.target_platform} build...", + f"[bold blue]Initializing {self.target_platform} build...", spinner="bouncingBall", ) self.progress = Progress(transient=True) @@ -668,1615 +64,61 @@ def handle(self, options: argparse.Namespace) -> None: 0, message=( f"Successfully built your [cyan]" - f"{self.platforms[self.options.target_platform]['status_text']}" + f"{self.platforms[self.target_platform]['status_text']}" f"[/cyan]! {self.emojis['success']} " f"Find it in [cyan]{self.rel_out_dir}[/cyan] directory. " f"{self.emojis['directory']}" + ( "\nRun [cyan]flet serve[/cyan] command to start a web server " "with your app. " - if self.options.target_platform == "web" + if self.target_platform == "web" else "" ) ), ) - def initialize_build(self): - assert self.options - self.emojis = { - "checkmark": "[green]OK[/]" if self.no_rich_output else "✅", - "loading": "" if self.no_rich_output else "⏳", - "success": "" if self.no_rich_output else "🥳", - "directory": "" if self.no_rich_output else "📁", - } - - self.python_app_path = Path(self.options.python_app_path).resolve() - self.skip_flutter_doctor = ( - self.skip_flutter_doctor or self.options.skip_flutter_doctor - ) - self.package_platform = self.platforms[self.options.target_platform][ - "package_platform" - ] - self.config_platform = self.platforms[self.options.target_platform][ - "config_platform" - ] - - if not ( - os.path.exists(self.python_app_path) or os.path.isdir(self.python_app_path) - ): - self.cleanup( - 1, - f"Path to Flet app does not exist or is not a directory: " - f"{self.python_app_path}", - ) - - # get `flutter` and `dart` executables from PATH - self.flutter_exe = self.find_flutter_batch("flutter") - self.dart_exe = self.find_flutter_batch("dart") - - if ( - not self.flutter_exe - or not self.dart_exe - or not self.flutter_version_valid() - ): - self.install_flutter() - - if self.verbose > 0: - console.log("Flutter executable:", self.flutter_exe, style=verbose2_style) - console.log("Dart executable:", self.dart_exe, style=verbose2_style) - - if self.package_platform == "Android": - self.install_jdk() - self.install_android_sdk() - - self.rel_out_dir = self.options.output_dir or os.path.join( - "build", self.platforms[self.options.target_platform]["dist"] - ) - - self.build_dir = self.python_app_path.joinpath("build") - self.flutter_dir = self.build_dir.joinpath("flutter") - self.flutter_packages_dir = self.build_dir.joinpath("flutter-packages") - self.flutter_packages_temp_dir = self.build_dir.joinpath( - "flutter-packages-temp" - ) - self.out_dir = ( - Path(self.options.output_dir).resolve() - if self.options.output_dir - else self.python_app_path.joinpath(self.rel_out_dir) - ) - self.pubspec_path = str(self.flutter_dir.joinpath("pubspec.yaml")) - self.get_pyproject = load_pyproject_toml(self.python_app_path) - - def flutter_version_valid(self): - version_results = self.run( - [ - self.flutter_exe, - "--version", - "--no-version-check", - "--suppress-analytics", - ], - cwd=os.getcwd(), - capture_output=True, - ) - if version_results.returncode == 0 and version_results.stdout: - match = re.search(r"Flutter (\d+\.\d+\.\d+)", version_results.stdout) - if match: - flutter_version = version.parse(match.group(1)) - - # validate installed Flutter version - return ( - flutter_version.major == MINIMAL_FLUTTER_VERSION.major - and flutter_version.minor == MINIMAL_FLUTTER_VERSION.minor - ) - else: - console.log(1, "Failed to validate Flutter version.") - return False - - def install_flutter(self): - self.update_status( - f"[bold blue]Installing Flutter {MINIMAL_FLUTTER_VERSION}..." - ) - from flet_cli.utils.flutter import install_flutter - - flutter_dir = install_flutter( - str(MINIMAL_FLUTTER_VERSION), self.log_stdout, progress=self.progress - ) - ext = ".bat" if platform.system() == "Windows" else "" - self.flutter_exe = os.path.join(flutter_dir, "bin", f"flutter{ext}") - self.dart_exe = os.path.join(flutter_dir, "bin", f"dart{ext}") - path_env = cleanup_path( - cleanup_path(os.environ.get("PATH", ""), "flutter"), "dart" - ) - self.env["PATH"] = os.pathsep.join([os.path.join(flutter_dir, "bin"), path_env]) - - # desktop mode - if self.config_platform in ["macos", "windows", "linux"]: - if self.verbose > 0: - console.log( - "Ensure Flutter has desktop support enabled", - style=verbose1_style, - ) - config_result = self.run( - [ - self.flutter_exe, - "config", - "--no-version-check", - "--suppress-analytics", - f"--enable-{self.config_platform}-desktop", - ], - cwd=os.getcwd(), - capture_output=self.verbose < 1, - ) - if config_result.returncode != 0: - if config_result.stdout: - console.log(config_result.stdout, style=verbose1_style) - if config_result.stderr: - console.log(config_result.stderr, style=error_style) - self.cleanup(config_result.returncode) - - console.log( - f"Flutter {MINIMAL_FLUTTER_VERSION} installed {self.emojis['checkmark']}" - ) - - def install_jdk(self): - from flet_cli.utils.jdk import install_jdk - - self.update_status("[bold blue]Installing JDK...") - jdk_dir = install_jdk(self.log_stdout, progress=self.progress) - self.env["JAVA_HOME"] = jdk_dir - - # config flutter's JDK dir - if self.verbose > 0: - console.log( - "Configuring Flutter's path to JDK", - style=verbose1_style, - ) - config_result = self.run( - [ - self.flutter_exe, - "config", - "--no-version-check", - "--suppress-analytics", - f"--jdk-dir={jdk_dir}", - ], - cwd=os.getcwd(), - capture_output=self.verbose < 1, - ) - if config_result.returncode != 0: - if config_result.stdout: - console.log(config_result.stdout, style=verbose1_style) - if config_result.stderr: - console.log(config_result.stderr, style=error_style) - self.cleanup(config_result.returncode) - - console.log(f"JDK installed {self.emojis['checkmark']}") - - def install_android_sdk(self): - from flet_cli.utils.android_sdk import AndroidSDK - - self.update_status("[bold blue]Installing Android SDK...") - self.env["ANDROID_HOME"] = AndroidSDK( - self.env["JAVA_HOME"], self.log_stdout, progress=self.progress - ).install() - console.log(f"Android SDK installed {self.emojis['checkmark']}") - - def validate_target_platform(self): - assert self.options - if ( - self.current_platform - not in self.platforms[self.options.target_platform]["can_be_run_on"] - or self.options.show_platform_matrix - ): - can_build_message = ( - "can't" - if self.current_platform - not in self.platforms[self.options.target_platform]["can_be_run_on"] - else "can" - ) - # replace "Darwin" with "macOS" for user-friendliness - self.current_platform = ( - "macOS" if self.current_platform == "Darwin" else self.current_platform - ) - # highlight the current platform in the build matrix table - self.platform_matrix_table.rows[ - list(self.platforms.keys()).index(self.options.target_platform) - ].style = "bold red1" - console.log(self.platform_matrix_table) - - message = f"You {can_build_message} build " - f"[cyan]{self.options.target_platform}[/] on " - f"[magenta]{self.current_platform}[/]." - self.cleanup(1, message) - - def validate_entry_point(self): - assert self.options - assert self.python_app_path - assert self.get_pyproject - - self.package_app_path = Path(self.python_app_path) - if self.get_pyproject("tool.flet.app.path"): - self.package_app_path = self.python_app_path.joinpath( - cast(str, self.get_pyproject("tool.flet.app.path")) - ) - - self.python_module_name = Path( - self.options.module_name - or cast(str, self.get_pyproject("tool.flet.app.module")) - or "main" - ).stem - self.python_module_filename = f"{self.python_module_name}.py" - if not self.package_app_path.joinpath(self.python_module_filename).exists(): - self.cleanup( - 1, - f"{self.python_module_filename} not found in the root of Flet " - "app directory. Use --module-name option to specify an entry point " - "for your Flet app.", - ) - - def setup_template_data(self): + def add_flutter_build_args(self, args: list[str]): assert self.options - assert self.python_app_path + assert self.build_dir assert self.get_pyproject + assert self.template_data + assert self.target_platform - base_url = ( - ( - self.options.base_url - or cast(str, self.get_pyproject("tool.flet.web.base_url")) - or "/" - ) - .strip("/") - .strip() - ) - project_name_orig = ( - self.options.project_name - or self.get_pyproject("project.name") - or self.get_pyproject("tool.poetry.name") - or self.python_app_path.name - ) - project_name_slug = slugify(cast(str, project_name_orig)) - project_name = project_name_slug.replace("-", "_") - product_name = ( - self.options.product_name - or self.get_pyproject("tool.flet.product") - or project_name_orig - ) - - split_per_abi = ( - self.options.split_per_abi - if self.options.split_per_abi is not None - else ( - self.get_pyproject("tool.flet.android.split_per_abi") - if self.get_pyproject("tool.flet.android.split_per_abi") is not None - else False - ) + args.extend( + ["build", self.platforms[self.target_platform]["flutter_build_command"]] ) - info_plist = {} - macos_entitlements = { - "com.apple.security.app-sandbox": False, - "com.apple.security.cs.allow-jit": True, - "com.apple.security.network.client": True, - "com.apple.security.network.server": True, - "com.apple.security.files.user-selected.read-write": True, - } - android_permissions = {"android.permission.INTERNET": True} - android_features = { - "android.software.leanback": False, - "android.hardware.touchscreen": False, - } - android_meta_data = {"io.flutter.embedding.android.EnableImpeller": "false"} + if self.target_platform in "apk" and self.template_data["split_per_abi"]: + args.append("--split-per-abi") - # merge values from "--permissions" arg: - for p in ( - self.options.permissions - or self.get_pyproject("tool.flet.permissions") - or [] - ): - if p in self.cross_platform_permissions: - info_plist.update(self.cross_platform_permissions[p]["info_plist"]) - macos_entitlements.update( - self.cross_platform_permissions[p]["macos_entitlements"] - ) - android_permissions.update( - self.cross_platform_permissions[p]["android_permissions"] - ) - android_features.update( - self.cross_platform_permissions[p]["android_features"] + if self.target_platform in ["ipa"]: + if self.template_data["ios_provisioning_profile"]: + args.extend( + [ + "--export-options-plist", + "ios/exportOptions.plist", + ] ) - - info_plist = merge_dict( - info_plist, - ( - self.get_pyproject("tool.flet.macos.info") - if self.package_platform == "Darwin" - else self.get_pyproject("tool.flet.ios.info") - ) - or {}, - ) - - # parse --info-plist - for p in self.options.info_plist: - i = p.find("=") - if i > -1: - k = p[:i] - v = p[i + 1 :] - info_plist[k] = True if v == "True" else False if v == "False" else v - else: - self.cleanup(1, f"Invalid Info.plist option: {p}") - - macos_entitlements = merge_dict( - macos_entitlements, - self.get_pyproject("tool.flet.macos.entitlement") or {}, - ) - - # parse --macos-entitlements - for p in self.options.macos_entitlements: - i = p.find("=") - if i > -1: - macos_entitlements[p[:i]] = p[i + 1 :] == "True" - else: - self.cleanup(1, f"Invalid macOS entitlement option: {p}") - - android_permissions = merge_dict( - android_permissions, - self.get_pyproject("tool.flet.android.permission") or {}, - ) - - # parse --android-permissions - for p in self.options.android_permissions: - i = p.find("=") - if i > -1: - android_permissions[p[:i]] = p[i + 1 :] == "True" - else: - self.cleanup(1, f"Invalid Android permission option: {p}") - - android_features = merge_dict( - android_features, - self.get_pyproject("tool.flet.android.feature") or {}, - ) - - # parse --android-features - for p in self.options.android_features: - i = p.find("=") - if i > -1: - android_features[p[:i]] = p[i + 1 :] == "True" - else: - self.cleanup(1, f"Invalid Android feature option: {p}") - - android_meta_data = merge_dict( - android_meta_data, - self.get_pyproject("tool.flet.android.meta_data") or {}, - ) - - # parse --android-meta-data - for p in self.options.android_meta_data: - i = p.find("=") - if i > -1: - android_meta_data[p[:i]] = p[i + 1 :] else: - self.cleanup(1, f"Invalid Android meta-data option: {p}") - - deep_linking_scheme = ( - self.get_pyproject("tool.flet.ios.deep_linking.scheme") - if self.package_platform == "iOS" - else ( - self.get_pyproject("tool.flet.android.deep_linking.scheme") - if self.package_platform == "Android" - else self.get_pyproject("tool.flet.deep_linking.scheme") - ) - ) - - deep_linking_host = ( - self.get_pyproject("tool.flet.ios.deep_linking.host") - if self.package_platform == "iOS" - else ( - self.get_pyproject("tool.flet.android.deep_linking.host") - if self.package_platform == "Android" - else self.get_pyproject("tool.flet.deep_linking.host") - ) - ) - - if self.options.deep_linking_scheme and self.options.deep_linking_host: - deep_linking_scheme = self.options.deep_linking_scheme - deep_linking_host = self.options.deep_linking_host + args.append("--no-codesign") - target_arch = ( - self.options.target_arch - or self.get_pyproject(f"tool.flet.{self.config_platform}.target_arch") - or self.get_pyproject("tool.flet.target_arch") + build_number = self.options.build_number or self.get_pyproject( + "tool.flet.build_number" ) + if build_number: + args.extend(["--build-number", str(build_number)]) - ios_export_method = ( - self.options.ios_export_method - or self.get_pyproject("tool.flet.ios.export_method") - or "debugging" + build_version = ( + self.options.build_version + or self.get_pyproject("project.version") + or self.get_pyproject("tool.poetry.version") ) - - ios_export_method_opts = ( - self.get_pyproject("tool.flet.ios.export_methods").get(ios_export_method) - if self.get_pyproject("tool.flet.ios.export_methods") - else {} - ) or {} - - ios_provisioning_profile = ( - self.options.ios_provisioning_profile - or self.get_pyproject("tool.flet.ios.provisioning_profile") - or ios_export_method_opts.get("provisioning_profile") - ) - - ios_signing_certificate = ( - self.options.ios_signing_certificate - or self.get_pyproject("tool.flet.ios.signing_certificate") - or ios_export_method_opts.get("signing_certificate") - ) - - ios_export_options = ( - self.get_pyproject("tool.flet.ios.export_options") - or ios_export_method_opts.get("export_options") - or {} - ) - - ios_team_id = ( - self.options.ios_team_id - or self.get_pyproject("tool.flet.ios.team_id") - or ios_export_method_opts.get("team_id") - ) - - if self.options.target_platform in ["ipa"] and not ios_provisioning_profile: - console.print( - Panel( - "This build will generate an .xcarchive (Xcode Archive). " - "To produce an .ipa (iOS App Package), please specify " - "a Provisioning Profile.", - style=warning_style, - ) - ) - - assert self.flutter_dir - self.template_data = { - "out_dir": self.flutter_dir.name, - "sep": os.sep, - "python_module_name": self.python_module_name, - "route_url_strategy": ( - self.options.route_url_strategy - or self.get_pyproject("tool.flet.web.route_url_strategy") - or "path" - ), - "web_renderer": ( - self.options.web_renderer - or self.get_pyproject("tool.flet.web.renderer") - or "auto" - ), - "pwa_background_color": ( - self.options.pwa_background_color - or self.get_pyproject("tool.flet.web.pwa_background_color") - ), - "pwa_theme_color": ( - self.options.pwa_theme_color - or self.get_pyproject("tool.flet.web.pwa_theme_color") - ), - "no_wasm": ( - self.options.no_wasm - or self.get_pyproject("tool.flet.web.wasm") == False # noqa: E712 - ), - "no_cdn": ( - self.options.no_cdn or self.get_pyproject("tool.flet.web.cdn") == False # noqa: E712 - ), - "base_url": f"/{base_url}/" if base_url else "/", - "split_per_abi": split_per_abi, - "project_name": project_name, - "project_name_slug": project_name_slug, - "product_name": product_name, - "description": ( - self.options.description - or self.get_pyproject("project.description") - or self.get_pyproject("tool.poetry.description") - ), - "org_name": self.options.org_name - or self.get_pyproject(f"tool.flet.{self.config_platform}.org") - or self.get_pyproject("tool.flet.org"), - "bundle_id": self.options.bundle_id - or self.get_pyproject(f"tool.flet.{self.config_platform}.bundle_id") - or self.get_pyproject("tool.flet.bundle_id"), - "company_name": ( - self.options.company_name or self.get_pyproject("tool.flet.company") - ), - "copyright": self.options.copyright - or self.get_pyproject("tool.flet.copyright"), - "ios_export_method": ios_export_method, - "ios_provisioning_profile": ios_provisioning_profile, - "ios_signing_certificate": ios_signing_certificate, - "ios_export_options": ios_export_options, - "ios_team_id": ios_team_id, - "options": { - "package_platform": self.package_platform, - "config_platform": self.config_platform, - "target_arch": ( - target_arch - if isinstance(target_arch, list) - else [target_arch] - if isinstance(target_arch, str) - else [] - ), - "info_plist": info_plist, - "macos_entitlements": macos_entitlements, - "android_permissions": android_permissions, - "android_features": android_features, - "android_meta_data": android_meta_data, - "deep_linking": { - "scheme": deep_linking_scheme, - "host": deep_linking_host, - }, - "android_signing": self.options.android_signing_key_store is not None, - }, - "flutter": {"dependencies": list(self.flutter_dependencies.keys())}, - "pyproject": self.get_pyproject(), - } - - def create_flutter_project(self, second_pass=False): - assert self.options - assert self.get_pyproject - assert self.flutter_dir - assert self.template_data - assert self.build_dir - assert self.pubspec_path - - hash = HashStamp( - self.build_dir / ".hash" / f"template-{'2' if second_pass else '1'}" - ) - - template_url = ( - self.options.template - or self.get_pyproject("tool.flet.template.url") - or DEFAULT_TEMPLATE_URL - ) - hash.update(template_url) - - template_ref = self.options.template_ref or self.get_pyproject( - "tool.flet.template.ref" - ) - if not template_ref: - template_ref = ( - version.Version(flet.version.version).base_version - if flet.version.version - else update_version() - ) - hash.update(template_ref) - - template_dir = self.options.template_dir or self.get_pyproject( - "tool.flet.template.dir" - ) - hash.update(template_dir) - hash.update(self.template_data) - - hash_changed = hash.has_changed() - - if hash_changed: - # if options.clear_cache is set, delete any existing Flutter bootstrap - # project directory - if ( - self.options.clear_cache - and self.flutter_dir.exists() - and not second_pass - ): - if self.verbose > 1: - console.log(f"Deleting {self.flutter_dir}", style=verbose2_style) - shutil.rmtree(self.flutter_dir, ignore_errors=True) - - # create a new Flutter bootstrap project directory, if non-existent - if not second_pass: - self.flutter_dir.mkdir(parents=True, exist_ok=True) - self.update_status( - "[bold blue]Creating Flutter bootstrap project from " - f'{template_url} with ref "{template_ref}"...' - ) - - try: - from cookiecutter.main import cookiecutter - - cookiecutter( - template=template_url, - checkout=template_ref, - directory=template_dir, - output_dir=str(self.flutter_dir.parent), - no_input=True, - overwrite_if_exists=True, - extra_context={ - k: v for k, v in self.template_data.items() if v is not None - }, - ) - except Exception as e: - shutil.rmtree(self.flutter_dir) - self.cleanup(1, f"{e}") - - pyproject_pubspec = self.get_pyproject("tool.flet.flutter.pubspec") - - if pyproject_pubspec: - pubspec = self.load_yaml(self.pubspec_path) - pubspec = merge_dict(pubspec, pyproject_pubspec) - self.save_yaml(self.pubspec_path, pubspec) - - # make backup of pubspec.yaml - shutil.copyfile(self.pubspec_path, f"{self.pubspec_path}.orig") - - if not second_pass: - console.log( - f"Created Flutter bootstrap project from {template_url} " - f'with ref "{template_ref}" {self.emojis["checkmark"]}' - ) - - hash.commit() - - return hash_changed - - def register_flutter_extensions(self): - assert self.flutter_packages_dir - assert self.flutter_packages_temp_dir - assert isinstance(self.flutter_dependencies, dict) - assert self.template_data - assert self.build_dir - - if self.flutter_packages_temp_dir.exists(): - # copy packages from temp to permanent location - if self.flutter_packages_dir.exists(): - shutil.rmtree(self.flutter_packages_dir, ignore_errors=True) - shutil.move(self.flutter_packages_temp_dir, self.flutter_packages_dir) - - if self.flutter_packages_dir.exists(): - self.update_status("[bold blue]Registering Flutter user extensions...") - - for fp in os.listdir(self.flutter_packages_dir): - if (self.flutter_packages_dir / fp / "pubspec.yaml").exists(): - ext_dir = str(self.flutter_packages_dir / fp) - if self.verbose > 0: - console.log(f"Found Flutter extension at {ext_dir}") - self.flutter_dependencies[fp] = {"path": ext_dir} - - self.template_data["flutter"]["dependencies"] = list( - self.flutter_dependencies.keys() - ) - - console.log( - f"Registered Flutter user extensions {self.emojis['checkmark']}" - ) - - def update_flutter_dependencies(self): - assert self.pubspec_path - assert self.template_data - assert self.get_pyproject - assert self.build_dir - assert isinstance(self.flutter_dependencies, dict) - - pubspec = self.load_yaml(self.pubspec_path) - - # merge dependencies to a dest pubspec.yaml - for k, v in self.flutter_dependencies.items(): - pubspec["dependencies"][k] = v - - # make sure project_name is not named as any of the dependencies - for dep in pubspec["dependencies"]: - if dep == self.template_data["project_name"]: - self.cleanup( - 1, - f"Project name cannot have the same name as one of its " - f"dependencies: {dep}. Use --project option to specify " - "a different project name.", - ) - - self.save_yaml(self.pubspec_path, pubspec) - - def customize_icons(self): - assert self.package_app_path - assert self.flutter_dir - assert self.options - assert self.get_pyproject - assert self.pubspec_path - assert self.build_dir - - hash = HashStamp(self.build_dir / ".hash" / "icons") - - pubspec_origin_path = f"{self.pubspec_path}.orig" - pubspec = self.load_yaml(pubspec_origin_path) - - copy_ops = [] - self.assets_path = self.package_app_path.joinpath("assets") - if self.assets_path.exists(): - images_dir = "images" - images_path = self.flutter_dir.joinpath(images_dir) - images_path.mkdir(exist_ok=True) - - # copy icons - default_icon = self.find_platform_image( - self.assets_path, images_path, "icon", copy_ops, hash - ) - ios_icon = self.find_platform_image( - self.assets_path, images_path, "icon_ios", copy_ops, hash - ) - android_icon = self.find_platform_image( - self.assets_path, images_path, "icon_android", copy_ops, hash - ) - web_icon = self.find_platform_image( - self.assets_path, images_path, "icon_web", copy_ops, hash - ) - windows_icon = self.find_platform_image( - self.assets_path, images_path, "icon_windows", copy_ops, hash - ) - macos_icon = self.find_platform_image( - self.assets_path, images_path, "icon_macos", copy_ops, hash - ) - - self.fallback_image( - pubspec, "flutter_launcher_icons.image_path", [default_icon], images_dir - ) - self.fallback_image( - pubspec, - "flutter_launcher_icons.image_path_ios", - [ios_icon, default_icon], - images_dir, - ) - self.fallback_image( - pubspec, - "flutter_launcher_icons.image_path_android", - [android_icon, default_icon], - images_dir, - ) - self.fallback_image( - pubspec, - "flutter_launcher_icons.adaptive_icon_foreground", - [android_icon, default_icon], - images_dir, - ) - self.fallback_image( - pubspec, - "flutter_launcher_icons.web.image_path", - [web_icon, default_icon], - images_dir, - ) - self.fallback_image( - pubspec, - "flutter_launcher_icons.windows.image_path", - [windows_icon, default_icon], - images_dir, - ) - self.fallback_image( - pubspec, - "flutter_launcher_icons.macos.image_path", - [macos_icon, default_icon], - images_dir, - ) - - adaptive_icon_background = ( - self.options.android_adaptive_icon_background - or self.get_pyproject("tool.flet.android.adaptive_icon_background") - ) - if adaptive_icon_background: - pubspec["flutter_launcher_icons"]["adaptive_icon_background"] = ( - adaptive_icon_background - ) - - # check if pubspec changed - hash.update(Path(pubspec_origin_path).stat().st_mtime) - hash.update(pubspec["flutter_launcher_icons"]) - - # save pubspec.yaml - if hash.has_changed(): - if copy_ops: - self.update_status("[bold blue]Customizing app icons...") - for op in copy_ops: - if self.verbose > 0: - console.log( - f"Copying image {op[0]} to {op[1]}", style=verbose1_style - ) - shutil.copy(op[0], op[1]) - console.log(f"Customized app icons {self.emojis['checkmark']}") - - updated_pubspec = self.load_yaml(self.pubspec_path) - updated_pubspec["flutter_launcher_icons"] = pubspec[ - "flutter_launcher_icons" - ] - self.save_yaml(self.pubspec_path, updated_pubspec) - - self.update_status("[bold blue]Generating app icons...") - - # icons - icons_result = self.run( - [ - self.dart_exe, - "run", - "--suppress-analytics", - "flutter_launcher_icons", - ], - cwd=str(self.flutter_dir), - capture_output=self.verbose < 1, - ) - if icons_result.returncode != 0: - if icons_result.stdout: - console.log(icons_result.stdout, style=verbose1_style) - if icons_result.stderr: - console.log(icons_result.stderr, style=error_style) - self.cleanup(icons_result.returncode) - console.log(f"Generated app icons {self.emojis['checkmark']}") - - hash.commit() - - def customize_splash_images(self): - assert self.package_app_path - assert self.flutter_dir - assert self.options - assert self.get_pyproject - assert self.pubspec_path - assert self.build_dir - - if self.options.target_platform not in ["web", "ipa", "apk", "aab"]: - return - - hash = HashStamp(self.build_dir / ".hash" / "splashes") - - pubspec_origin_path = f"{self.pubspec_path}.orig" - - pubspec = self.load_yaml(pubspec_origin_path) - - copy_ops = [] - self.assets_path = self.package_app_path.joinpath("assets") - if self.assets_path.exists(): - images_dir = "images" - images_path = self.flutter_dir.joinpath(images_dir) - images_path.mkdir(exist_ok=True) - - # copy icons - default_icon = self.find_platform_image( - self.assets_path, images_path, "icon", copy_ops, hash - ) - - # copy splash images - default_splash = self.find_platform_image( - self.assets_path, images_path, "splash", copy_ops, hash - ) - default_dark_splash = self.find_platform_image( - self.assets_path, images_path, "splash_dark", copy_ops, hash - ) - ios_splash = self.find_platform_image( - self.assets_path, images_path, "splash_ios", copy_ops, hash - ) - ios_dark_splash = self.find_platform_image( - self.assets_path, images_path, "splash_dark_ios", copy_ops, hash - ) - android_splash = self.find_platform_image( - self.assets_path, images_path, "splash_android", copy_ops, hash - ) - android_dark_splash = self.find_platform_image( - self.assets_path, images_path, "splash_dark_android", copy_ops, hash - ) - web_splash = self.find_platform_image( - self.assets_path, images_path, "splash_web", copy_ops, hash - ) - web_dark_splash = self.find_platform_image( - self.assets_path, images_path, "splash_dark_web", copy_ops, hash - ) - self.fallback_image( - pubspec, - "flutter_native_splash.image", - [default_splash, default_icon], - images_dir, - ) - self.fallback_image( - pubspec, - "flutter_native_splash.image_dark", - [default_dark_splash, default_splash, default_icon], - images_dir, - ) - self.fallback_image( - pubspec, - "flutter_native_splash.image_ios", - [ios_splash, default_splash, default_icon], - images_dir, - ) - self.fallback_image( - pubspec, - "flutter_native_splash.image_dark_ios", - [ - ios_dark_splash, - default_dark_splash, - ios_splash, - default_splash, - default_icon, - ], - images_dir, - ) - self.fallback_image( - pubspec, - "flutter_native_splash.image_android", - [android_splash, default_splash, default_icon], - images_dir, - ) - self.fallback_image( - pubspec, - "flutter_native_splash.android_12.image", - [android_splash, default_splash, default_icon], - images_dir, - ) - self.fallback_image( - pubspec, - "flutter_native_splash.image_dark_android", - [ - android_dark_splash, - default_dark_splash, - android_splash, - default_splash, - default_icon, - ], - images_dir, - ) - self.fallback_image( - pubspec, - "flutter_native_splash.android_12.image_dark", - [ - android_dark_splash, - default_dark_splash, - android_splash, - default_splash, - default_icon, - ], - images_dir, - ) - self.fallback_image( - pubspec, - "flutter_native_splash.image_web", - [web_splash, default_splash, default_icon], - images_dir, - ) - self.fallback_image( - pubspec, - "flutter_native_splash.image_dark_web", - [ - web_dark_splash, - default_dark_splash, - web_splash, - default_splash, - default_icon, - ], - images_dir, - ) - - # splash colors - splash_color = ( - self.options.splash_color - or self.get_pyproject(f"tool.flet.{self.config_platform}.splash.color") - or self.get_pyproject("tool.flet.splash.color") - ) - if splash_color: - pubspec["flutter_native_splash"]["color"] = splash_color - pubspec["flutter_native_splash"]["android_12"]["color"] = splash_color - - splash_dark_color = ( - self.options.splash_dark_color - or self.get_pyproject(f"tool.flet.{self.config_platform}.splash.dark_color") - or self.get_pyproject("tool.flet.splash.dark_color") - ) - if splash_dark_color: - pubspec["flutter_native_splash"]["color_dark"] = splash_dark_color - pubspec["flutter_native_splash"]["android_12"]["color_dark"] = ( - splash_dark_color - ) - - splash_icon_bgcolor = self.get_pyproject( - f"tool.flet.{self.config_platform}.splash.icon_bgcolor" - ) or self.get_pyproject("tool.flet.splash.icon_bgcolor") - - if splash_icon_bgcolor: - pubspec["flutter_native_splash"]["android_12"]["icon_background_color"] = ( - splash_icon_bgcolor - ) - - splash_icon_dark_bgcolor = self.get_pyproject( - f"tool.flet.{self.config_platform}.splash.icon_dark_bgcolor" - ) or self.get_pyproject("tool.flet.splash.icon_dark_bgcolor") - - if splash_icon_dark_bgcolor: - pubspec["flutter_native_splash"]["android_12"][ - "icon_background_color_dark" - ] = splash_icon_dark_bgcolor - - # enable/disable splashes - pubspec["flutter_native_splash"]["web"] = ( - not self.options.no_web_splash - if self.options.no_web_splash is not None - else ( - self.get_pyproject("tool.flet.splash.web") - if self.get_pyproject("tool.flet.splash.web") is not None - else True - ) - ) - pubspec["flutter_native_splash"]["ios"] = ( - not self.options.no_ios_splash - if self.options.no_ios_splash is not None - else ( - self.get_pyproject("tool.flet.splash.ios") - if self.get_pyproject("tool.flet.splash.ios") is not None - else True - ) - ) - pubspec["flutter_native_splash"]["android"] = ( - not self.options.no_android_splash - if self.options.no_android_splash is not None - else ( - self.get_pyproject("tool.flet.splash.android") - if self.get_pyproject("tool.flet.splash.android") is not None - else True - ) - ) - - # check if pubspec changed - hash.update(Path(pubspec_origin_path).stat().st_mtime) - hash.update(pubspec["flutter_native_splash"]) - - # save pubspec.yaml - if hash.has_changed(): - if copy_ops: - self.update_status("[bold blue]Customizing app splash images...") - for op in copy_ops: - if self.verbose > 0: - console.log( - f"Copying image {op[0]} to {op[1]}", style=verbose1_style - ) - shutil.copy(op[0], op[1]) - console.log(f"Customized app splash images {self.emojis['checkmark']}") - - updated_pubspec = self.load_yaml(self.pubspec_path) - updated_pubspec["flutter_native_splash"] = pubspec["flutter_native_splash"] - self.save_yaml(self.pubspec_path, updated_pubspec) - - # splash screens - self.update_status("[bold blue]Generating splash screens...") - splash_result = self.run( - [ - self.dart_exe, - "run", - "--suppress-analytics", - "flutter_native_splash:create", - ], - cwd=str(self.flutter_dir), - capture_output=self.verbose < 1, - ) - if splash_result.returncode != 0: - if splash_result.stdout: - console.log(splash_result.stdout, style=verbose1_style) - if splash_result.stderr: - console.log(splash_result.stderr, style=error_style) - self.cleanup(splash_result.returncode) - console.log(f"Generated splash screens {self.emojis['checkmark']}") - - hash.commit() - - def fallback_image(self, pubspec, yaml_path: str, images: list, images_dir: str): - d = pubspec - pp = yaml_path.split(".") - for p in pp[:-1]: - d = d[p] - for image in images: - if image: - d[pp[-1]] = f"{images_dir}/{image}" - return - - def package_python_app(self): - assert self.options - assert self.get_pyproject - assert self.python_app_path - assert self.package_app_path - assert self.build_dir - assert self.flutter_dir - assert self.flutter_packages_dir - assert self.flutter_packages_temp_dir - assert self.template_data - - hash = HashStamp(self.build_dir / ".hash" / "package") - - self.update_status("[bold blue]Packaging Python app...") - package_args = [ - self.dart_exe, - "run", - "--suppress-analytics", - "serious_python:main", - "package", - str(self.package_app_path), - "--platform", - self.package_platform, - ] - - if self.template_data["options"]["target_arch"]: - package_args.extend( - ["--arch"] + self.template_data["options"]["target_arch"] - ) - - package_env = {} - - # requirements - requirements_txt = self.python_app_path.joinpath("requirements.txt") - - toml_dependencies = ( - get_poetry_dependencies(self.get_pyproject("tool.poetry.dependencies")) - or get_project_dependencies(self.get_pyproject("project.dependencies")) - or [] - ) - - platform_dependencies = get_project_dependencies( - self.get_pyproject(f"tool.flet.{self.config_platform}.dependencies") - ) - if platform_dependencies: - toml_dependencies.extend(platform_dependencies) - - dev_packages_configured = False - if len(toml_dependencies) > 0: - dev_packages = ( - self.get_pyproject(f"tool.flet.{self.config_platform}.dev_packages") - or self.get_pyproject("tool.flet.dev_packages") - or [] - ) - if len(dev_packages) > 0: - for i in range(0, len(toml_dependencies)): - package_name = Requirement(toml_dependencies[i]).name - if package_name in dev_packages: - package_location = dev_packages[package_name] - dev_path = Path(package_location) - if not dev_path.is_absolute(): - dev_path = (self.python_app_path / dev_path).resolve() - if dev_path.exists(): - toml_dependencies[i] = f"{package_name} @ file://{dev_path}" - else: - toml_dependencies[i] = ( - f"{package_name} @ {package_location}" - ) - dev_packages_configured = True - if dev_packages_configured: - toml_dependencies.append("--no-cache-dir") - - for toml_dep in toml_dependencies: - package_args.extend(["-r", toml_dep]) - - elif requirements_txt.exists(): - if self.verbose > 1: - with open(requirements_txt, encoding="utf-8") as f: - reqs_txt_contents = f.read() - console.log( - f"Contents of requirements.txt: {reqs_txt_contents}", - style=verbose2_style, - ) - hash.update(reqs_txt_contents) - package_args.extend(["-r", "-r", "-r", str(requirements_txt)]) - else: - flet_version = ( - flet.version.version if flet.version.version else update_version() - ) - package_args.extend(["-r", f"flet=={flet_version}"]) - - # site-packages variable - if self.package_platform != "Pyodide": - package_env["SERIOUS_PYTHON_SITE_PACKAGES"] = str( - self.build_dir / "site-packages" - ) - - # flutter-packages variable - if self.flutter_packages_temp_dir.exists(): - shutil.rmtree(self.flutter_packages_temp_dir) - - package_env["SERIOUS_PYTHON_FLUTTER_PACKAGES"] = str( - self.flutter_packages_temp_dir - ) - - # exclude - exclude_list = ["build"] - - app_exclude = ( - self.options.exclude - or self.get_pyproject(f"tool.flet.{self.config_platform}.app.exclude") - or self.get_pyproject("tool.flet.app.exclude") - ) - if app_exclude: - exclude_list.extend(app_exclude) - - if self.options.target_platform == "web": - exclude_list.append("assets") - package_args.extend(["--exclude", ",".join(exclude_list)]) - - # source-packages - source_packages = ( - self.options.source_packages - or self.get_pyproject(f"tool.flet.{self.config_platform}.source_packages") - or self.get_pyproject("tool.flet.source_packages") - ) - if source_packages: - package_env["SERIOUS_PYTHON_ALLOW_SOURCE_DISTRIBUTIONS"] = ",".join( - source_packages - ) - - if self.get_bool_setting(self.options.compile_app, "compile.app", False): - package_args.append("--compile-app") - - if self.get_bool_setting( - self.options.compile_packages, "compile.packages", False - ): - package_args.append("--compile-packages") - - cleanup_app = self.get_bool_setting( - self.options.cleanup_app, "cleanup.app", False - ) - cleanup_packages = self.get_bool_setting( - self.options.cleanup_packages, "cleanup.packages", True - ) - - # TODO: should be deprecated - if self.get_bool_setting(None, "compile.cleanup", False): - cleanup_app = cleanup_packages = True - - if cleanup_app_files := ( - self.options.cleanup_app_files - or self.get_pyproject(f"tool.flet.{self.config_platform}.cleanup.app_files") - or self.get_pyproject("tool.flet.cleanup.app_files") - ): - package_args.extend(["--cleanup-app-files", ",".join(cleanup_app_files)]) - cleanup_app = True - - if cleanup_package_files := ( - self.options.cleanup_package_files - or self.get_pyproject( - f"tool.flet.{self.config_platform}.cleanup.package_files" - ) - or self.get_pyproject("tool.flet.cleanup.package_files") - ): - package_args.extend( - ["--cleanup-package-files", ",".join(cleanup_package_files)] - ) - cleanup_packages = True - - if cleanup_app: - package_args.append("--cleanup-app") - - if cleanup_packages: - package_args.append("--cleanup-packages") - - if self.verbose > 1: - package_args.append("--verbose") - - # check if site-packages installation could be skipped - for arg in package_args: - hash.update(arg) - - if not dev_packages_configured: - if not hash.has_changed(): - package_args.append("--skip-site-packages") - else: - if self.flutter_packages_dir.exists(): - shutil.rmtree(self.flutter_packages_dir, ignore_errors=True) - - package_result = self.run( - package_args, - cwd=str(self.flutter_dir), - env=package_env, - capture_output=self.verbose < 1, - ) - - if package_result.returncode != 0: - if package_result.stdout: - console.log(package_result.stdout, style=verbose1_style) - if package_result.stderr: - console.log(package_result.stderr, style=error_style) - self.cleanup(package_result.returncode) - - hash.commit() - - # make sure app/app.zip exists - app_zip_path = self.flutter_dir.joinpath("app", "app.zip") - if not os.path.exists(app_zip_path): - self.cleanup(1, "Flet app package app/app.zip was not created.") - - console.log(f"Packaged Python app {self.emojis['checkmark']}") - - def get_bool_setting(self, cli_option, pyproj_setting, default_value): - assert self.get_pyproject - return ( - cli_option - if cli_option is not None - else ( - self.get_pyproject(f"tool.flet.{self.config_platform}.{pyproj_setting}") - if self.get_pyproject( - f"tool.flet.{self.config_platform}.{pyproj_setting}" - ) - is not None - else ( - self.get_pyproject(f"tool.flet.{pyproj_setting}") - if self.get_pyproject(f"tool.flet.{pyproj_setting}") is not None - else default_value - ) - ) - ) - - def flutter_build(self): - assert self.options - assert self.build_dir - assert self.get_pyproject - assert self.template_data - - self.update_status( - f"[bold blue]Building [cyan]" - f"{self.platforms[self.options.target_platform]['status_text']}[/cyan]..." - ) - # flutter build - build_args = [ - self.flutter_exe, - "build", - self.platforms[self.options.target_platform]["flutter_build_command"], - "--no-version-check", - "--suppress-analytics", - ] - - build_env = {} - - # site-packages variable - if self.package_platform != "Pyodide": - build_env["SERIOUS_PYTHON_SITE_PACKAGES"] = str( - self.build_dir / "site-packages" - ) - - if self.package_platform == "Pyodide" and not self.template_data["no_wasm"]: - build_args.append("--wasm") - - android_signing_key_store = ( - self.options.android_signing_key_store - or self.get_pyproject("tool.flet.android.signing.key_store") - ) - if android_signing_key_store: - build_env["FLET_ANDROID_SIGNING_KEY_STORE"] = android_signing_key_store - - key_store_password = ( - self.options.android_signing_key_store_password - or os.getenv("FLET_ANDROID_SIGNING_KEY_STORE_PASSWORD") - ) - key_password = self.options.android_signing_key_password or os.getenv( - "FLET_ANDROID_SIGNING_KEY_PASSWORD" - ) - if key_store_password or key_password: - build_env["FLET_ANDROID_SIGNING_KEY_STORE_PASSWORD"] = ( - key_store_password if key_store_password else key_password - ) - build_env["FLET_ANDROID_SIGNING_KEY_PASSWORD"] = ( - key_password if key_password else key_store_password - ) - - android_signing_key_alias = ( - self.options.android_signing_key_alias - or self.get_pyproject("tool.flet.android.signing.key_alias") - ) - if android_signing_key_alias: - build_env["FLET_ANDROID_SIGNING_KEY_ALIAS"] = android_signing_key_alias - - if ( - self.options.target_platform in "apk" - and self.template_data["split_per_abi"] - ): - build_args.append("--split-per-abi") - - if self.options.target_platform in ["ipa"]: - if self.template_data["ios_provisioning_profile"]: - build_args.extend( - [ - "--export-options-plist", - "ios/exportOptions.plist", - ] - ) - else: - build_args.append("--no-codesign") - - build_number = self.options.build_number or self.get_pyproject( - "tool.flet.build_number" - ) - if build_number: - build_args.extend(["--build-number", str(build_number)]) - - build_version = ( - self.options.build_version - or self.get_pyproject("project.version") - or self.get_pyproject("tool.poetry.version") - ) - if build_version: - build_args.extend(["--build-name", build_version]) + if build_version: + args.extend(["--build-name", build_version]) for arg in ( self.get_pyproject(f"tool.flet.{self.config_platform}.flutter.build_args") or self.get_pyproject("tool.flet.flutter.build_args") or [] ): - build_args.append(arg) - - if self.options.flutter_build_args: - for flutter_build_arg_arr in self.options.flutter_build_args: - build_args.extend(flutter_build_arg_arr) - - if self.verbose > 1: - build_args.append("--verbose") - - build_result = self.run( - build_args, - cwd=str(self.flutter_dir), - env=build_env, - capture_output=self.verbose < 1, - ) - - if ( - build_result.returncode != 0 - or "Encountered error while creating the IPA" in str(build_result.stderr) - ): - if build_result.stdout: - console.log(build_result.stdout, style=verbose1_style) - if build_result.stderr: - console.log(build_result.stderr, style=error_style) - self.cleanup(build_result.returncode if build_result.returncode else 1) - console.log( - f"Built [cyan]{self.platforms[self.options.target_platform]['status_text']}" - f"[/cyan] {self.emojis['checkmark']}", - ) - - def copy_build_output(self): - assert self.template_data - assert self.options - assert self.flutter_dir - assert self.out_dir - assert self.assets_path - - self.update_status( - f"[bold blue]Copying build to [cyan]{self.rel_out_dir}[/cyan] directory...", - ) - arch = platform.machine().lower() - if arch in {"x86_64", "amd64"}: - arch = "x64" - elif arch in {"arm64", "aarch64"}: - arch = "arm64" - - def make_ignore_fn(out_dir, out_glob): - def ignore(path, names): - if path == out_dir and out_glob != "*": - return [f for f in os.listdir(path) if f != out_glob] - return [] - - return ignore - - for build_output in self.platforms[self.options.target_platform]["outputs"]: - build_output_dir = ( - str(self.flutter_dir.joinpath(build_output)) - .replace("{arch}", arch) - .replace("{project_name}", self.template_data["project_name"]) - .replace("{product_name}", self.template_data["product_name"]) - ) - - if self.verbose > 0: - console.log( - "Copying build output from: " + build_output_dir, - style=verbose1_style, - ) - - build_output_glob = os.path.basename(build_output_dir) - build_output_dir = os.path.dirname(build_output_dir) - if not os.path.exists(build_output_dir): - continue - - if self.out_dir.exists(): - shutil.rmtree(str(self.out_dir)) - self.out_dir.mkdir(parents=True, exist_ok=True) - - # copy build result to out_dir - copy_tree( - build_output_dir, - str(self.out_dir), - ignore=make_ignore_fn(build_output_dir, build_output_glob), - ) - - if self.options.target_platform == "web" and self.assets_path.exists(): - # copy `assets` directory contents to the output directory - copy_tree(str(self.assets_path), str(self.out_dir)) - - console.log( - f"Copied build to [cyan]{self.rel_out_dir}[/cyan] " - f"directory {self.emojis['checkmark']}" - ) - - def find_platform_image( - self, - src_path: Path, - dest_path: Path, - image_name: str, - copy_ops: list, - hash: HashStamp, - ): - images = glob.glob(str(src_path.joinpath(f"{image_name}.*"))) - if len(images) > 0: - if self.verbose > 0: - console.log( - f'Found "{image_name}" image at {images[0]}', style=verbose1_style - ) - copy_ops.append((images[0], dest_path)) - hash.update(images[0]) - hash.update(Path(images[0]).stat().st_mtime) - return Path(images[0]).name - return None - - def find_flutter_batch(self, exe_filename: str): - batch_path = shutil.which(exe_filename) - if not batch_path: - return None - if is_windows() and batch_path.endswith(".file"): - return batch_path.replace(".file", ".bat") - return batch_path - - def run(self, args, cwd, env: Optional[dict] = None, capture_output=True): - if self.verbose > 0: - console.log(f"Run subprocess: {args}", style=verbose1_style) - - return processes.run( - args, - cwd, - env={**self.env, **env} if env else self.env, - capture_output=capture_output, - log=self.log_stdout, - ) - - def cleanup(self, exit_code: int, message: Optional[str] = None): - if exit_code == 0: - msg = message or f"Success! {self.emojis['success']}" - self.live.update(Panel(msg), refresh=True) - else: - msg = ( - message - if message is not None - else "Error building Flet app - see the log of failed command above." - ) - - # windows has been reported to raise encoding errors - # when running `flutter doctor` - # so skip running `flutter doctor` if no_rich_output is True - # and platform is Windows - if not ( - (self.no_rich_output and self.current_platform == "Windows") - or self.skip_flutter_doctor - ): - status = console.status( - "[bold blue]Running Flutter doctor...", - spinner="bouncingBall", - ) - self.live.update( - Group(Panel(msg, style=error_style), status), refresh=True - ) - self.run_flutter_doctor() - self.live.update(Panel(msg, style=error_style), refresh=True) - - sys.exit(exit_code) - - def run_flutter_doctor(self): - flutter_doctor = self.run( - [self.flutter_exe, "doctor", "--no-version-check", "--suppress-analytics"], - cwd=os.getcwd(), - capture_output=True, - ) - if flutter_doctor.returncode == 0 and flutter_doctor.stdout: - console.log(flutter_doctor.stdout, style=verbose1_style) - - def update_status(self, status): - if self.no_rich_output: - console.log(status) - else: - self.status.update(status) - - def log_stdout(self, message): - if self.verbose > 0: - console.log( - message, - end="", - style=verbose2_style, - markup=False, - ) - - def load_yaml(self, path): - with open(str(path), encoding="utf-8") as f: - return yaml.safe_load(f) - - def save_yaml(self, path, doc): - with open(str(path), "w", encoding="utf-8") as f: - yaml.dump(doc, f) + args.append(arg) diff --git a/sdk/python/packages/flet-cli/src/flet_cli/commands/debug.py b/sdk/python/packages/flet-cli/src/flet_cli/commands/debug.py new file mode 100644 index 000000000..a12d2fbed --- /dev/null +++ b/sdk/python/packages/flet-cli/src/flet_cli/commands/debug.py @@ -0,0 +1,145 @@ +import argparse +import os +import platform + +from rich.console import Group +from rich.live import Live +from rich.progress import Progress + +from flet_cli.commands.flutter_base import BaseFlutterCommand, console, verbose2_style + + +class Command(BaseFlutterCommand): + """ + Run a Flet Python app in debug mode on a specified platform (desktop, web, mobile). + """ + + def __init__(self, parser: argparse.ArgumentParser) -> None: + super().__init__(parser) + + self.debug_platforms = { + "windows": {"target_platform": "windows", "device_id": "windows"}, + "macos": {"target_platform": "macos", "device_id": "macos"}, + "linux": {"target_platform": "linux", "device_id": "linux"}, + "web": {"target_platform": "web", "device_id": "chrome"}, + "ios": {"target_platform": "ipa", "device_id": None}, + "android": {"target_platform": "apk", "device_id": None}, + } + self.device_id = None + + def add_arguments(self, parser: argparse.ArgumentParser) -> None: + parser.add_argument( + "platform", + type=str, + nargs="?", + choices=["macos", "linux", "windows", "web", "ios", "android"], + help="The target platform to run the app on", + ) + parser.add_argument( + "--device-id", + "-d", + type=str, + dest="device_id", + help="Device ID to run the app on for iOS and Android builds.", + ) + parser.add_argument( + "--show-devices", + dest="show_devices", + action="store_true", + default=False, + help="Show connected devices for iOS and Android builds.", + ) + parser.add_argument( + "--route", + type=str, + dest="route", + help="Route to open the app on for web, iOS and Android builds.", + ) + super().add_arguments(parser) + + def handle(self, options: argparse.Namespace) -> None: + super().handle(options) + self.options.output_dir = None # disable output dir for debug builds + if self.options: + if "platform" in self.options and self.options.platform: + self.debug_platform = self.options.platform + else: + self.debug_platform = platform.system().lower() + if self.debug_platform == "darwin": + self.debug_platform = "macos" + self.target_platform = self.debug_platforms[self.debug_platform][ + "target_platform" + ] + self.device_id = self.debug_platforms[self.debug_platform]["device_id"] + if self.options.device_id: + self.device_id = self.options.device_id + + self.status = console.status( + f"[bold blue]Initializing {self.target_platform} debug session...", + spinner="bouncingBall", + ) + self.progress = Progress(transient=True) + self.no_rich_output = self.no_rich_output or self.options.no_rich_output + self.verbose = self.options.verbose + with Live(Group(self.status, self.progress), console=console) as self.live: + self.check_device_id() + self.initialize_build() + if self.options.show_devices: + self.run_flutter_devices() + self.live.update("", refresh=True) + return + self.validate_target_platform() + self.validate_entry_point() + self.setup_template_data() + self.create_flutter_project() + self.package_python_app() + self.register_flutter_extensions() + if self.create_flutter_project(second_pass=True): + self.update_flutter_dependencies() + self.customize_icons() + self.customize_splash_images() + self.flutter_build() + + self.cleanup( + 0, + message=( + f"Successfully built your [cyan]" + f"{self.platforms[self.target_platform]['status_text']}" + f"[/cyan]! {self.emojis['success']} " + f"Find it in [cyan]{self.rel_out_dir}[/cyan] directory. " + f"{self.emojis['directory']}" + + ( + "\nRun [cyan]flet serve[/cyan] command to start a web server " + "with your app. " + if self.target_platform == "web" + else "" + ) + ), + ) + + def check_device_id(self): + if self.device_id is None and self.debug_platform in [ + "ios", + "android", + ]: + self.skip_flutter_doctor = True + self.cleanup( + 1, + "Device ID must be specified for iOS and Android debug builds.\n" + "Use --device-id option to specify it.\n" + "Use --show-devices option to list connected devices.", + ) + + def add_flutter_build_args(self, args: list[str]): + assert self.device_id + args.extend(["run", "-d", self.device_id]) + + def run_flutter_devices(self): + self.update_status("[bold blue]Checking connected devices...") + flutter_devices = self.run( + [self.flutter_exe, "devices", "--no-version-check", "--suppress-analytics"], + cwd=os.getcwd(), + capture_output=True, + ) + if flutter_devices.returncode == 0 and flutter_devices.stdout: + console.log(flutter_devices.stdout, style=verbose2_style) diff --git a/sdk/python/packages/flet-cli/src/flet_cli/commands/flutter_base.py b/sdk/python/packages/flet-cli/src/flet_cli/commands/flutter_base.py new file mode 100644 index 000000000..e7ec58533 --- /dev/null +++ b/sdk/python/packages/flet-cli/src/flet_cli/commands/flutter_base.py @@ -0,0 +1,2200 @@ +import argparse +import glob +import os +import platform +import re +import shutil +import sys +from pathlib import Path +from typing import Optional, cast + +import yaml +from packaging import version +from packaging.requirements import Requirement +from rich.console import Console, Group +from rich.panel import Panel +from rich.style import Style +from rich.table import Column, Table +from rich.theme import Theme + +import flet.version +import flet_cli.utils.processes as processes +from flet.utils import cleanup_path, copy_tree, is_windows, slugify +from flet.utils.platform_utils import get_bool_env_var +from flet.version import update_version +from flet_cli.commands.base import BaseCommand +from flet_cli.utils.hash_stamp import HashStamp +from flet_cli.utils.merge import merge_dict +from flet_cli.utils.project_dependencies import ( + get_poetry_dependencies, + get_project_dependencies, +) +from flet_cli.utils.pyproject_toml import load_pyproject_toml + +PYODIDE_ROOT_URL = "https://cdn.jsdelivr.net/pyodide/v0.27.7/full" +DEFAULT_TEMPLATE_URL = "gh:flet-dev/flet-build-template" + +MINIMAL_FLUTTER_VERSION = version.Version("3.38.2") + +no_rich_output = get_bool_env_var("FLET_CLI_NO_RICH_OUTPUT") + +error_style = Style(color="red", bold=True) +warning_style = Style(color="yellow", bold=True) +console = Console( + log_path=False, + theme=Theme({"log.message": "green bold"}), + force_terminal=not no_rich_output, +) +verbose1_style = Style(dim=True, bold=False) +verbose2_style = Style(color="bright_black", bold=False) + + +class BaseFlutterCommand(BaseCommand): + """ + A base Flutter CLI subcommand + """ + + def __init__(self, parser: argparse.ArgumentParser) -> None: + super().__init__(parser) + + self.env = {} + self.pubspec_path = None + self.rel_out_dir = None + self.assets_path = None + self.target_platform = None + self.debug_platform = None + self.package_platform = None + self.config_platform = None + self.flutter_dependencies = {} + self.package_app_path = None + self.options = None + self.template_data = None + self.python_module_filename = None + self.out_dir = None + self.python_module_name = None + self.get_pyproject = None + self.python_app_path = None + self.emojis = {} + self.dart_exe = None + self.verbose = False + self.build_dir = None + self.flutter_dir: Optional[Path] = None + self.flutter_packages_dir = None + self.flutter_packages_temp_dir = None + self.flutter_exe = None + self.skip_flutter_doctor = get_bool_env_var("FLET_CLI_SKIP_FLUTTER_DOCTOR") + self.no_rich_output = no_rich_output + self.current_platform = platform.system() + self.platforms = { + "windows": { + "package_platform": "Windows", + "config_platform": "windows", + "flutter_build_command": "windows", + "status_text": "Windows app", + "outputs": ["build/windows/x64/runner/Release/*"], + "dist": "windows", + "can_be_run_on": ["Windows"], + }, + "macos": { + "package_platform": "Darwin", + "config_platform": "macos", + "flutter_build_command": "macos", + "status_text": "macOS bundle", + "outputs": ["build/macos/Build/Products/Release/{product_name}.app"], + "dist": "macos", + "can_be_run_on": ["Darwin"], + }, + "linux": { + "package_platform": "Linux", + "config_platform": "linux", + "flutter_build_command": "linux", + "status_text": "app for Linux", + "outputs": ["build/linux/{arch}/release/bundle/*"], + "dist": "linux", + "can_be_run_on": ["Linux"], + }, + "web": { + "package_platform": "Pyodide", + "config_platform": "web", + "flutter_build_command": "web", + "status_text": "web app", + "outputs": ["build/web/*"], + "dist": "web", + "can_be_run_on": ["Darwin", "Windows", "Linux"], + }, + "apk": { + "package_platform": "Android", + "config_platform": "android", + "flutter_build_command": "apk", + "status_text": ".apk for Android", + "outputs": ["build/app/outputs/flutter-apk/*"], + "dist": "apk", + "can_be_run_on": ["Darwin", "Windows", "Linux"], + }, + "aab": { + "package_platform": "Android", + "config_platform": "android", + "flutter_build_command": "appbundle", + "status_text": ".aab bundle for Android", + "outputs": ["build/app/outputs/bundle/release/*"], + "dist": "aab", + "can_be_run_on": ["Darwin", "Windows", "Linux"], + }, + "ipa": { + "package_platform": "iOS", + "config_platform": "ios", + "flutter_build_command": "ipa", + "status_text": ".ipa bundle for iOS", + "outputs": ["build/ios/archive/*", "build/ios/ipa/*"], + "dist": "ipa", + "can_be_run_on": ["Darwin"], + }, + } + + self.cross_platform_permissions = { + "location": { + "info_plist": { + "NSLocationWhenInUseUsageDescription": "This app uses location " + "service when in use.", + "NSLocationAlwaysAndWhenInUseUsageDescription": "This app uses " + "location service.", + }, + "macos_entitlements": { + "com.apple.security.personal-information.location": True + }, + "android_permissions": { + "android.permission.ACCESS_FINE_LOCATION": True, + "android.permission.ACCESS_COARSE_LOCATION": True, + "android.permission.ACCESS_BACKGROUND_LOCATION": True, + }, + "android_features": { + "android.hardware.location.network": False, + "android.hardware.location.gps": False, + }, + }, + "camera": { + "info_plist": { + "NSCameraUsageDescription": "This app uses the camera to capture " + "photos and videos." + }, + "macos_entitlements": {"com.apple.security.device.camera": True}, + "android_permissions": {"android.permission.CAMERA": True}, + "android_features": { + "android.hardware.camera": False, + "android.hardware.camera.any": False, + "android.hardware.camera.front": False, + "android.hardware.camera.external": False, + "android.hardware.camera.autofocus": False, + }, + }, + "microphone": { + "info_plist": { + "NSMicrophoneUsageDescription": "This app uses microphone to " + "record sounds.", + }, + "macos_entitlements": {"com.apple.security.device.audio-input": True}, + "android_permissions": { + "android.permission.RECORD_AUDIO": True, + "android.permission.WRITE_EXTERNAL_STORAGE": True, + "android.permission.READ_EXTERNAL_STORAGE": True, + }, + "android_features": {}, + }, + "photo_library": { + "info_plist": { + "NSPhotoLibraryUsageDescription": "This app saves photos and " + "videos to the photo library." + }, + "macos_entitlements": { + "com.apple.security.personal-information.photos-library": True + }, + "android_permissions": { + "android.permission.READ_MEDIA_VISUAL_USER_SELECTED": True + }, + "android_features": {}, + }, + } + + # create and display build-platform-matrix table + self.platform_matrix_table = Table( + Column("Command", style="cyan", justify="left"), + Column("Platform", style="magenta", justify="center"), + title="Build Platform Matrix", + header_style="bold", + show_lines=True, + ) + for p, info in self.platforms.items(): + self.platform_matrix_table.add_row( + "flet build " + p, + ", ".join(info["can_be_run_on"]).replace("Darwin", "macOS"), + ) + + def add_arguments(self, parser: argparse.ArgumentParser) -> None: + parser.add_argument( + "python_app_path", + type=str, + nargs="?", + default=".", + help="Path to a directory with a Flet Python program", + ) + parser.add_argument( + "--arch", + dest="target_arch", + nargs="+", + default=[], + help="Build for specific CPU architectures " + "(used in macOS and Android builds only). Example: `--arch arm64 x64`", + ) + parser.add_argument( + "--exclude", + dest="exclude", + nargs="+", + default=[], + help="Files and/or directories to exclude from the package", + ) + parser.add_argument( + "--clear-cache", + dest="clear_cache", + action="store_true", + default=None, + help="Remove any existing build cache before starting the build process", + ) + parser.add_argument( + "--project", + dest="project_name", + required=False, + help="Project name for the executable/bundle. " + "It is used in metadata and bundle IDs", + ) + parser.add_argument( + "--description", + dest="description", + required=False, + help="Short description of the application", + ) + parser.add_argument( + "--product", + dest="product_name", + required=False, + help="Display name of the app that is shown in window titles " + "and about app dialogs", + ) + parser.add_argument( + "--org", + dest="org_name", + required=False, + help="Organization name in reverse domain name notation, " + "e.g. `com.mycompany`, combined with project name and " + "used in bundle IDs and signing", + ) + parser.add_argument( + "--bundle-id", + dest="bundle_id", + required=False, + help="Bundle ID for the application, e.g. `com.mycompany.app-name`. " + "It is used as an iOS, Android, macOS and Linux bundle ID", + ) + parser.add_argument( + "--company", + dest="company_name", + required=False, + help="Company name to display in about app dialogs", + ) + parser.add_argument( + "--copyright", + dest="copyright", + required=False, + help="Copyright text to display in about app dialogs", + ) + parser.add_argument( + "--android-adaptive-icon-background", + dest="android_adaptive_icon_background", + required=False, + help="The color to be used to fill out the background of " + "Android adaptive icons", + ) + parser.add_argument( + "--splash-color", + dest="splash_color", + required=False, + help="Background color of app splash screen on iOS, Android and web", + ) + parser.add_argument( + "--splash-dark-color", + dest="splash_dark_color", + required=False, + help="Background color in dark mode of app splash screen on " + "iOS, Android and web", + ) + parser.add_argument( + "--no-web-splash", + dest="no_web_splash", + action="store_true", + default=None, + help="Disable splash screen on web platform", + ) + parser.add_argument( + "--no-ios-splash", + dest="no_ios_splash", + action="store_true", + default=None, + help="Disable splash screen on iOS platform", + ) + parser.add_argument( + "--no-android-splash", + dest="no_android_splash", + action="store_true", + default=None, + help="Disable splash screen on Android platform", + ) + parser.add_argument( + "--ios-team-id", + dest="ios_team_id", + type=str, + help="Apple developer team ID for signing iOS app bundle (ipa only)", + required=False, + ) + parser.add_argument( + "--ios-export-method", + dest="ios_export_method", + type=str, + required=False, + help="Export method for iOS app bundle (default: debugging)", + ) + parser.add_argument( + "--ios-provisioning-profile", + dest="ios_provisioning_profile", + type=str, + required=False, + help="Provisioning profile name or UUID that should be used to sign and " + "export iOS app bundle", + ) + parser.add_argument( + "--ios-signing-certificate", + dest="ios_signing_certificate", + type=str, + required=False, + help="Signing certificate name, SHA-1 hash, or automatic selector to use " + "for signing iOS app bundle", + ) + parser.add_argument( + "--base-url", + dest="base_url", + type=str, + help="Base URL from which the app is served (web only)", + ) + parser.add_argument( + "--web-renderer", + dest="web_renderer", + choices=["auto", "canvaskit", "skwasm"], + help="Flutter web renderer to use (web only)", + ) + parser.add_argument( + "--route-url-strategy", + dest="route_url_strategy", + choices=["path", "hash"], + help="Base URL path to serve the app from. " + "Useful if the app is hosted in a subdirectory (web only)", + ) + parser.add_argument( + "--pwa-background-color", + dest="pwa_background_color", + required=False, + help="Initial background color for your web app (web only)", + ) + parser.add_argument( + "--pwa-theme-color", + dest="pwa_theme_color", + required=False, + help="Default color for your web app's user interface (web only)", + ) + parser.add_argument( + "--no-wasm", + dest="no_wasm", + action="store_true", + default=False, + help="Disable WASM target for web build (web only)", + ) + parser.add_argument( + "--no-cdn", + dest="no_cdn", + action="store_true", + default=False, + help="Disable loading of CanvasKit, Pyodide and fonts from CDN", + ) + parser.add_argument( + "--split-per-abi", + dest="split_per_abi", + action="store_true", + default=None, + help="Split the APKs per ABIs (Android only)", + ) + parser.add_argument( + "--compile-app", + dest="compile_app", + action="store_true", + default=None, + help="Pre-compile app's `.py` files to `.pyc`", + ) + parser.add_argument( + "--compile-packages", + dest="compile_packages", + action="store_true", + default=None, + help="Pre-compile site packages' `.py` files to `.pyc`", + ) + parser.add_argument( + "--cleanup-app", + dest="cleanup_app", + action="store_true", + default=None, + help="Remove unnecessary app files upon packaging", + ) + parser.add_argument( + "--cleanup-app-files", + dest="cleanup_app_files", + action="append", + nargs="*", + help="The list of globs to delete extra app files and directories", + ) + parser.add_argument( + "--cleanup-packages", + dest="cleanup_packages", + action="store_true", + default=None, + help="Remove unnecessary package files upon packaging", + ) + parser.add_argument( + "--cleanup-package-files", + dest="cleanup_package_files", + action="append", + nargs="*", + help="The list of globs to delete extra package files and directories", + ) + parser.add_argument( + "--flutter-build-args", + dest="flutter_build_args", + action="append", + nargs="*", + help="Additional arguments for flutter build command", + ) + parser.add_argument( + "--source-packages", + dest="source_packages", + nargs="+", + default=[], + help="The list of Python packages to install from source distributions", + ) + parser.add_argument( + "--info-plist", + dest="info_plist", + nargs="+", + default=[], + help="The list of `=|True|False` pairs to add to Info.plist " + "for macOS and iOS builds (macos and ipa only)", + ) + parser.add_argument( + "--macos-entitlements", + dest="macos_entitlements", + nargs="+", + default=[], + help="The list of `=|True|False` entitlements for " + "macOS builds (macos only)", + ) + parser.add_argument( + "--android-features", + dest="android_features", + nargs="+", + default=[], + help="The list of `=True|False` features to add to " + "AndroidManifest.xml for Android builds (android only)", + ) + parser.add_argument( + "--android-permissions", + dest="android_permissions", + nargs="+", + default=[], + help="The list of `=True|False` permissions to add to " + "AndroidManifest.xml for Android builds (android only)", + ) + parser.add_argument( + "--android-meta-data", + dest="android_meta_data", + nargs="+", + default=[], + help="The list of `=` app meta-data entries to add to " + "AndroidManifest.xml for Android builds (android only)", + ) + parser.add_argument( + "--permissions", + dest="permissions", + nargs="+", + default=[], + choices=["location", "camera", "microphone", "photo_library"], + help="The list of pre-defined cross-platform permissions for iOS, Android " + "and macOS builds", + ) + parser.add_argument( + "--deep-linking-scheme", + dest="deep_linking_scheme", + help="Deep linking URL scheme to configure for iOS and Android builds, " + "i.g. `https` or `myapp`", + ) + parser.add_argument( + "--deep-linking-host", + dest="deep_linking_host", + help="Deep linking URL host for iOS and Android builds", + ) + parser.add_argument( + "--android-signing-key-store", + dest="android_signing_key_store", + help="path to an upload keystore `.jks` file for Android apps", + ) + parser.add_argument( + "--android-signing-key-store-password", + dest="android_signing_key_store_password", + help="Android signing store password", + ) + parser.add_argument( + "--android-signing-key-password", + dest="android_signing_key_password", + help="Android signing key password", + ) + parser.add_argument( + "--android-signing-key-alias", + dest="android_signing_key_alias", + default="upload", + help="Android signing key alias (default: upload)", + ) + parser.add_argument( + "--build-number", + dest="build_number", + type=int, + help="Build number - an identifier used as an internal version number", + ) + parser.add_argument( + "--build-version", + dest="build_version", + help="Build version - a `x.y.z` string used as the version number " + "shown to users", + ) + parser.add_argument( + "--module-name", + dest="module_name", + help="Python module name with an app entry point", + ) + parser.add_argument( + "--template", + dest="template", + type=str, + help="Directory containing Flutter bootstrap template, or a URL " + "to a git repository template", + ) + parser.add_argument( + "--template-dir", + dest="template_dir", + type=str, + help="Relative path to a Flutter bootstrap template in a repository", + ) + parser.add_argument( + "--template-ref", + dest="template_ref", + type=str, + help="The branch, tag or commit ID to checkout after cloning " + "the repository with Flutter bootstrap template", + ) + parser.add_argument( + "--show-platform-matrix", + action="store_true", + default=False, + help="Display the build platform matrix in a table, then exit", + ) + parser.add_argument( + "--no-rich-output", + action="store_true", + default=False, + help="Disable rich output and prefer plain text. Useful on Windows builds", + ) + parser.add_argument( + "--skip-flutter-doctor", + action="store_true", + default=False, + help="Skip running Flutter doctor upon failed builds", + ) + + def handle(self, options: argparse.Namespace) -> None: + self.options = options + if "target_platform" in self.options: + self.target_platform = self.options.target_platform + + def initialize_build(self): + assert self.options + assert self.target_platform + self.emojis = { + "checkmark": "[green]OK[/]" if self.no_rich_output else "✅", + "loading": "" if self.no_rich_output else "⏳", + "success": "" if self.no_rich_output else "🥳", + "directory": "" if self.no_rich_output else "📁", + } + + self.python_app_path = Path(self.options.python_app_path).resolve() + self.skip_flutter_doctor = ( + self.skip_flutter_doctor or self.options.skip_flutter_doctor + ) + self.package_platform = self.platforms[self.target_platform]["package_platform"] + self.config_platform = self.platforms[self.target_platform]["config_platform"] + + if not ( + os.path.exists(self.python_app_path) or os.path.isdir(self.python_app_path) + ): + self.cleanup( + 1, + f"Path to Flet app does not exist or is not a directory: " + f"{self.python_app_path}", + ) + + # get `flutter` and `dart` executables from PATH + self.flutter_exe = self.find_flutter_batch("flutter") + self.dart_exe = self.find_flutter_batch("dart") + + if ( + not self.flutter_exe + or not self.dart_exe + or not self.flutter_version_valid() + ): + self.install_flutter() + + if self.verbose > 0: + console.log("Flutter executable:", self.flutter_exe, style=verbose2_style) + console.log("Dart executable:", self.dart_exe, style=verbose2_style) + + if self.package_platform == "Android": + self.install_jdk() + self.install_android_sdk() + + self.rel_out_dir = self.options.output_dir or os.path.join( + "build", self.platforms[self.target_platform]["dist"] + ) + + self.build_dir = self.python_app_path.joinpath("build") + self.flutter_dir = self.build_dir.joinpath("flutter") + self.flutter_packages_dir = self.build_dir.joinpath("flutter-packages") + self.flutter_packages_temp_dir = self.build_dir.joinpath( + "flutter-packages-temp" + ) + self.out_dir = ( + Path(self.options.output_dir).resolve() + if self.options.output_dir + else self.python_app_path.joinpath(self.rel_out_dir) + ) + self.pubspec_path = str(self.flutter_dir.joinpath("pubspec.yaml")) + self.get_pyproject = load_pyproject_toml(self.python_app_path) + + def flutter_version_valid(self): + version_results = self.run( + [ + self.flutter_exe, + "--version", + "--no-version-check", + "--suppress-analytics", + ], + cwd=os.getcwd(), + capture_output=True, + ) + if version_results.returncode == 0 and version_results.stdout: + match = re.search(r"Flutter (\d+\.\d+\.\d+)", version_results.stdout) + if match: + flutter_version = version.parse(match.group(1)) + + # validate installed Flutter version + return ( + flutter_version.major == MINIMAL_FLUTTER_VERSION.major + and flutter_version.minor == MINIMAL_FLUTTER_VERSION.minor + ) + else: + console.log(1, "Failed to validate Flutter version.") + return False + + def install_flutter(self): + self.update_status( + f"[bold blue]Installing Flutter {MINIMAL_FLUTTER_VERSION}..." + ) + from flet_cli.utils.flutter import install_flutter + + flutter_dir = install_flutter( + str(MINIMAL_FLUTTER_VERSION), self.log_stdout, progress=self.progress + ) + ext = ".bat" if platform.system() == "Windows" else "" + self.flutter_exe = os.path.join(flutter_dir, "bin", f"flutter{ext}") + self.dart_exe = os.path.join(flutter_dir, "bin", f"dart{ext}") + path_env = cleanup_path( + cleanup_path(os.environ.get("PATH", ""), "flutter"), "dart" + ) + self.env["PATH"] = os.pathsep.join([os.path.join(flutter_dir, "bin"), path_env]) + + # desktop mode + if self.config_platform in ["macos", "windows", "linux"]: + if self.verbose > 0: + console.log( + "Ensure Flutter has desktop support enabled", + style=verbose1_style, + ) + config_result = self.run( + [ + self.flutter_exe, + "config", + "--no-version-check", + "--suppress-analytics", + f"--enable-{self.config_platform}-desktop", + ], + cwd=os.getcwd(), + capture_output=self.verbose < 1, + ) + if config_result.returncode != 0: + if config_result.stdout: + console.log(config_result.stdout, style=verbose1_style) + if config_result.stderr: + console.log(config_result.stderr, style=error_style) + self.cleanup(config_result.returncode) + + console.log( + f"Flutter {MINIMAL_FLUTTER_VERSION} installed {self.emojis['checkmark']}" + ) + + def install_jdk(self): + from flet_cli.utils.jdk import install_jdk + + self.update_status("[bold blue]Installing JDK...") + jdk_dir = install_jdk(self.log_stdout, progress=self.progress) + self.env["JAVA_HOME"] = jdk_dir + + # config flutter's JDK dir + if self.verbose > 0: + console.log( + "Configuring Flutter's path to JDK", + style=verbose1_style, + ) + config_result = self.run( + [ + self.flutter_exe, + "config", + "--no-version-check", + "--suppress-analytics", + f"--jdk-dir={jdk_dir}", + ], + cwd=os.getcwd(), + capture_output=self.verbose < 1, + ) + if config_result.returncode != 0: + if config_result.stdout: + console.log(config_result.stdout, style=verbose1_style) + if config_result.stderr: + console.log(config_result.stderr, style=error_style) + self.cleanup(config_result.returncode) + + console.log(f"JDK installed {self.emojis['checkmark']}") + + def install_android_sdk(self): + from flet_cli.utils.android_sdk import AndroidSDK + + self.update_status("[bold blue]Installing Android SDK...") + self.env["ANDROID_HOME"] = AndroidSDK( + self.env["JAVA_HOME"], self.log_stdout, progress=self.progress + ).install() + console.log(f"Android SDK installed {self.emojis['checkmark']}") + + def validate_target_platform(self): + assert self.options + assert self.target_platform + if ( + self.current_platform + not in self.platforms[self.target_platform]["can_be_run_on"] + or self.options.show_platform_matrix + ): + can_build_message = ( + "can't" + if self.current_platform + not in self.platforms[self.target_platform]["can_be_run_on"] + else "can" + ) + # replace "Darwin" with "macOS" for user-friendliness + self.current_platform = ( + "macOS" if self.current_platform == "Darwin" else self.current_platform + ) + # highlight the current platform in the build matrix table + self.platform_matrix_table.rows[ + list(self.platforms.keys()).index(self.target_platform) + ].style = "bold red1" + console.log(self.platform_matrix_table) + + message = f"You {can_build_message} build " + f"[cyan]{self.target_platform}[/] on " + f"[magenta]{self.current_platform}[/]." + self.cleanup(1, message) + + def validate_entry_point(self): + assert self.options + assert self.python_app_path + assert self.get_pyproject + + self.package_app_path = Path(self.python_app_path) + if self.get_pyproject("tool.flet.app.path"): + self.package_app_path = self.python_app_path.joinpath( + cast(str, self.get_pyproject("tool.flet.app.path")) + ) + + self.python_module_name = Path( + self.options.module_name + or cast(str, self.get_pyproject("tool.flet.app.module")) + or "main" + ).stem + self.python_module_filename = f"{self.python_module_name}.py" + if not self.package_app_path.joinpath(self.python_module_filename).exists(): + self.cleanup( + 1, + f"{self.python_module_filename} not found in the root of Flet " + "app directory. Use --module-name option to specify an entry point " + "for your Flet app.", + ) + + def setup_template_data(self): + assert self.options + assert self.python_app_path + assert self.get_pyproject + + base_url = ( + ( + self.options.base_url + or cast(str, self.get_pyproject("tool.flet.web.base_url")) + or "/" + ) + .strip("/") + .strip() + ) + project_name_orig = ( + self.options.project_name + or self.get_pyproject("project.name") + or self.get_pyproject("tool.poetry.name") + or self.python_app_path.name + ) + project_name_slug = slugify(cast(str, project_name_orig)) + project_name = project_name_slug.replace("-", "_") + product_name = ( + self.options.product_name + or self.get_pyproject("tool.flet.product") + or project_name_orig + ) + + split_per_abi = ( + self.options.split_per_abi + if self.options.split_per_abi is not None + else ( + self.get_pyproject("tool.flet.android.split_per_abi") + if self.get_pyproject("tool.flet.android.split_per_abi") is not None + else False + ) + ) + + info_plist = {} + macos_entitlements = { + "com.apple.security.app-sandbox": False, + "com.apple.security.cs.allow-jit": True, + "com.apple.security.network.client": True, + "com.apple.security.network.server": True, + "com.apple.security.files.user-selected.read-write": True, + } + android_permissions = {"android.permission.INTERNET": True} + android_features = { + "android.software.leanback": False, + "android.hardware.touchscreen": False, + } + android_meta_data = {"io.flutter.embedding.android.EnableImpeller": "false"} + + # merge values from "--permissions" arg: + for p in ( + self.options.permissions + or self.get_pyproject("tool.flet.permissions") + or [] + ): + if p in self.cross_platform_permissions: + info_plist.update(self.cross_platform_permissions[p]["info_plist"]) + macos_entitlements.update( + self.cross_platform_permissions[p]["macos_entitlements"] + ) + android_permissions.update( + self.cross_platform_permissions[p]["android_permissions"] + ) + android_features.update( + self.cross_platform_permissions[p]["android_features"] + ) + + info_plist = merge_dict( + info_plist, + ( + self.get_pyproject("tool.flet.macos.info") + if self.package_platform == "Darwin" + else self.get_pyproject("tool.flet.ios.info") + ) + or {}, + ) + + # parse --info-plist + for p in self.options.info_plist: + i = p.find("=") + if i > -1: + k = p[:i] + v = p[i + 1 :] + info_plist[k] = True if v == "True" else False if v == "False" else v + else: + self.cleanup(1, f"Invalid Info.plist option: {p}") + + macos_entitlements = merge_dict( + macos_entitlements, + self.get_pyproject("tool.flet.macos.entitlement") or {}, + ) + + # parse --macos-entitlements + for p in self.options.macos_entitlements: + i = p.find("=") + if i > -1: + macos_entitlements[p[:i]] = p[i + 1 :] == "True" + else: + self.cleanup(1, f"Invalid macOS entitlement option: {p}") + + android_permissions = merge_dict( + android_permissions, + self.get_pyproject("tool.flet.android.permission") or {}, + ) + + # parse --android-permissions + for p in self.options.android_permissions: + i = p.find("=") + if i > -1: + android_permissions[p[:i]] = p[i + 1 :] == "True" + else: + self.cleanup(1, f"Invalid Android permission option: {p}") + + android_features = merge_dict( + android_features, + self.get_pyproject("tool.flet.android.feature") or {}, + ) + + # parse --android-features + for p in self.options.android_features: + i = p.find("=") + if i > -1: + android_features[p[:i]] = p[i + 1 :] == "True" + else: + self.cleanup(1, f"Invalid Android feature option: {p}") + + android_meta_data = merge_dict( + android_meta_data, + self.get_pyproject("tool.flet.android.meta_data") or {}, + ) + + # parse --android-meta-data + for p in self.options.android_meta_data: + i = p.find("=") + if i > -1: + android_meta_data[p[:i]] = p[i + 1 :] + else: + self.cleanup(1, f"Invalid Android meta-data option: {p}") + + deep_linking_scheme = ( + self.get_pyproject("tool.flet.ios.deep_linking.scheme") + if self.package_platform == "iOS" + else ( + self.get_pyproject("tool.flet.android.deep_linking.scheme") + if self.package_platform == "Android" + else self.get_pyproject("tool.flet.deep_linking.scheme") + ) + ) + + deep_linking_host = ( + self.get_pyproject("tool.flet.ios.deep_linking.host") + if self.package_platform == "iOS" + else ( + self.get_pyproject("tool.flet.android.deep_linking.host") + if self.package_platform == "Android" + else self.get_pyproject("tool.flet.deep_linking.host") + ) + ) + + if self.options.deep_linking_scheme and self.options.deep_linking_host: + deep_linking_scheme = self.options.deep_linking_scheme + deep_linking_host = self.options.deep_linking_host + + target_arch = ( + self.options.target_arch + or self.get_pyproject(f"tool.flet.{self.config_platform}.target_arch") + or self.get_pyproject("tool.flet.target_arch") + ) + + ios_export_method = ( + self.options.ios_export_method + or self.get_pyproject("tool.flet.ios.export_method") + or "debugging" + ) + + ios_export_method_opts = ( + self.get_pyproject("tool.flet.ios.export_methods").get(ios_export_method) + if self.get_pyproject("tool.flet.ios.export_methods") + else {} + ) or {} + + ios_provisioning_profile = ( + self.options.ios_provisioning_profile + or self.get_pyproject("tool.flet.ios.provisioning_profile") + or ios_export_method_opts.get("provisioning_profile") + ) + + ios_signing_certificate = ( + self.options.ios_signing_certificate + or self.get_pyproject("tool.flet.ios.signing_certificate") + or ios_export_method_opts.get("signing_certificate") + ) + + ios_export_options = ( + self.get_pyproject("tool.flet.ios.export_options") + or ios_export_method_opts.get("export_options") + or {} + ) + + ios_team_id = ( + self.options.ios_team_id + or self.get_pyproject("tool.flet.ios.team_id") + or ios_export_method_opts.get("team_id") + ) + + if ( + self.target_platform in ["ipa"] + and not ios_provisioning_profile + and not self.debug_platform + ): + console.print( + Panel( + "This build will generate an .xcarchive (Xcode Archive). " + "To produce an .ipa (iOS App Package), please specify " + "a Provisioning Profile.", + style=warning_style, + ) + ) + + assert self.flutter_dir + self.template_data = { + "out_dir": self.flutter_dir.name, + "sep": os.sep, + "python_module_name": self.python_module_name, + "route_url_strategy": ( + self.options.route_url_strategy + or self.get_pyproject("tool.flet.web.route_url_strategy") + or "path" + ), + "web_renderer": ( + self.options.web_renderer + or self.get_pyproject("tool.flet.web.renderer") + or "auto" + ), + "pwa_background_color": ( + self.options.pwa_background_color + or self.get_pyproject("tool.flet.web.pwa_background_color") + ), + "pwa_theme_color": ( + self.options.pwa_theme_color + or self.get_pyproject("tool.flet.web.pwa_theme_color") + ), + "no_wasm": ( + self.options.no_wasm + or self.get_pyproject("tool.flet.web.wasm") == False # noqa: E712 + ), + "no_cdn": ( + self.options.no_cdn or self.get_pyproject("tool.flet.web.cdn") == False # noqa: E712 + ), + "base_url": f"/{base_url}/" if base_url else "/", + "split_per_abi": split_per_abi, + "project_name": project_name, + "project_name_slug": project_name_slug, + "product_name": product_name, + "description": ( + self.options.description + or self.get_pyproject("project.description") + or self.get_pyproject("tool.poetry.description") + ), + "org_name": self.options.org_name + or self.get_pyproject(f"tool.flet.{self.config_platform}.org") + or self.get_pyproject("tool.flet.org"), + "bundle_id": self.options.bundle_id + or self.get_pyproject(f"tool.flet.{self.config_platform}.bundle_id") + or self.get_pyproject("tool.flet.bundle_id"), + "company_name": ( + self.options.company_name or self.get_pyproject("tool.flet.company") + ), + "copyright": self.options.copyright + or self.get_pyproject("tool.flet.copyright"), + "ios_export_method": ios_export_method, + "ios_provisioning_profile": ios_provisioning_profile, + "ios_signing_certificate": ios_signing_certificate, + "ios_export_options": ios_export_options, + "ios_team_id": ios_team_id, + "options": { + "package_platform": self.package_platform, + "config_platform": self.config_platform, + "target_arch": ( + target_arch + if isinstance(target_arch, list) + else [target_arch] + if isinstance(target_arch, str) + else [] + ), + "info_plist": info_plist, + "macos_entitlements": macos_entitlements, + "android_permissions": android_permissions, + "android_features": android_features, + "android_meta_data": android_meta_data, + "deep_linking": { + "scheme": deep_linking_scheme, + "host": deep_linking_host, + }, + "android_signing": self.options.android_signing_key_store is not None, + }, + "flutter": {"dependencies": list(self.flutter_dependencies.keys())}, + "pyproject": self.get_pyproject(), + } + + def create_flutter_project(self, second_pass=False): + assert self.options + assert self.get_pyproject + assert self.flutter_dir + assert self.template_data + assert self.build_dir + assert self.pubspec_path + + hash = HashStamp( + self.build_dir / ".hash" / f"template-{'2' if second_pass else '1'}" + ) + + template_url = ( + self.options.template + or self.get_pyproject("tool.flet.template.url") + or DEFAULT_TEMPLATE_URL + ) + hash.update(template_url) + + template_ref = self.options.template_ref or self.get_pyproject( + "tool.flet.template.ref" + ) + if not template_ref: + template_ref = ( + version.Version(flet.version.version).base_version + if flet.version.version + else update_version() + ) + hash.update(template_ref) + + template_dir = self.options.template_dir or self.get_pyproject( + "tool.flet.template.dir" + ) + hash.update(template_dir) + hash.update(self.template_data) + + hash_changed = hash.has_changed() + + if hash_changed: + # if options.clear_cache is set, delete any existing Flutter bootstrap + # project directory + if ( + self.options.clear_cache + and self.flutter_dir.exists() + and not second_pass + ): + if self.verbose > 1: + console.log(f"Deleting {self.flutter_dir}", style=verbose2_style) + shutil.rmtree(self.flutter_dir, ignore_errors=True) + + # create a new Flutter bootstrap project directory, if non-existent + if not second_pass: + self.flutter_dir.mkdir(parents=True, exist_ok=True) + self.update_status( + "[bold blue]Creating Flutter bootstrap project from " + f'{template_url} with ref "{template_ref}"...' + ) + + try: + from cookiecutter.main import cookiecutter + + cookiecutter( + template=template_url, + checkout=template_ref, + directory=template_dir, + output_dir=str(self.flutter_dir.parent), + no_input=True, + overwrite_if_exists=True, + extra_context={ + k: v for k, v in self.template_data.items() if v is not None + }, + ) + except Exception as e: + shutil.rmtree(self.flutter_dir) + self.cleanup(1, f"{e}") + + pyproject_pubspec = self.get_pyproject("tool.flet.flutter.pubspec") + + if pyproject_pubspec: + pubspec = self.load_yaml(self.pubspec_path) + pubspec = merge_dict(pubspec, pyproject_pubspec) + self.save_yaml(self.pubspec_path, pubspec) + + # make backup of pubspec.yaml + shutil.copyfile(self.pubspec_path, f"{self.pubspec_path}.orig") + + if not second_pass: + console.log( + f"Created Flutter bootstrap project from {template_url} " + f'with ref "{template_ref}" {self.emojis["checkmark"]}' + ) + + hash.commit() + + return hash_changed + + def register_flutter_extensions(self): + assert self.flutter_packages_dir + assert self.flutter_packages_temp_dir + assert isinstance(self.flutter_dependencies, dict) + assert self.template_data + assert self.build_dir + + if self.flutter_packages_temp_dir.exists(): + # copy packages from temp to permanent location + if self.flutter_packages_dir.exists(): + shutil.rmtree(self.flutter_packages_dir, ignore_errors=True) + shutil.move(self.flutter_packages_temp_dir, self.flutter_packages_dir) + + if self.flutter_packages_dir.exists(): + self.update_status("[bold blue]Registering Flutter user extensions...") + + for fp in os.listdir(self.flutter_packages_dir): + if (self.flutter_packages_dir / fp / "pubspec.yaml").exists(): + ext_dir = str(self.flutter_packages_dir / fp) + if self.verbose > 0: + console.log(f"Found Flutter extension at {ext_dir}") + self.flutter_dependencies[fp] = {"path": ext_dir} + + self.template_data["flutter"]["dependencies"] = list( + self.flutter_dependencies.keys() + ) + + console.log( + f"Registered Flutter user extensions {self.emojis['checkmark']}" + ) + + def update_flutter_dependencies(self): + assert self.pubspec_path + assert self.template_data + assert self.get_pyproject + assert self.build_dir + assert isinstance(self.flutter_dependencies, dict) + + pubspec = self.load_yaml(self.pubspec_path) + + # merge dependencies to a dest pubspec.yaml + for k, v in self.flutter_dependencies.items(): + pubspec["dependencies"][k] = v + + # make sure project_name is not named as any of the dependencies + for dep in pubspec["dependencies"]: + if dep == self.template_data["project_name"]: + self.cleanup( + 1, + f"Project name cannot have the same name as one of its " + f"dependencies: {dep}. Use --project option to specify " + "a different project name.", + ) + + self.save_yaml(self.pubspec_path, pubspec) + + def customize_icons(self): + assert self.package_app_path + assert self.flutter_dir + assert self.options + assert self.get_pyproject + assert self.pubspec_path + assert self.build_dir + + hash = HashStamp(self.build_dir / ".hash" / "icons") + + pubspec_origin_path = f"{self.pubspec_path}.orig" + pubspec = self.load_yaml(pubspec_origin_path) + + copy_ops = [] + self.assets_path = self.package_app_path.joinpath("assets") + if self.assets_path.exists(): + images_dir = "images" + images_path = self.flutter_dir.joinpath(images_dir) + images_path.mkdir(exist_ok=True) + + # copy icons + default_icon = self.find_platform_image( + self.assets_path, images_path, "icon", copy_ops, hash + ) + ios_icon = self.find_platform_image( + self.assets_path, images_path, "icon_ios", copy_ops, hash + ) + android_icon = self.find_platform_image( + self.assets_path, images_path, "icon_android", copy_ops, hash + ) + web_icon = self.find_platform_image( + self.assets_path, images_path, "icon_web", copy_ops, hash + ) + windows_icon = self.find_platform_image( + self.assets_path, images_path, "icon_windows", copy_ops, hash + ) + macos_icon = self.find_platform_image( + self.assets_path, images_path, "icon_macos", copy_ops, hash + ) + + self.fallback_image( + pubspec, "flutter_launcher_icons.image_path", [default_icon], images_dir + ) + self.fallback_image( + pubspec, + "flutter_launcher_icons.image_path_ios", + [ios_icon, default_icon], + images_dir, + ) + self.fallback_image( + pubspec, + "flutter_launcher_icons.image_path_android", + [android_icon, default_icon], + images_dir, + ) + self.fallback_image( + pubspec, + "flutter_launcher_icons.adaptive_icon_foreground", + [android_icon, default_icon], + images_dir, + ) + self.fallback_image( + pubspec, + "flutter_launcher_icons.web.image_path", + [web_icon, default_icon], + images_dir, + ) + self.fallback_image( + pubspec, + "flutter_launcher_icons.windows.image_path", + [windows_icon, default_icon], + images_dir, + ) + self.fallback_image( + pubspec, + "flutter_launcher_icons.macos.image_path", + [macos_icon, default_icon], + images_dir, + ) + + adaptive_icon_background = ( + self.options.android_adaptive_icon_background + or self.get_pyproject("tool.flet.android.adaptive_icon_background") + ) + if adaptive_icon_background: + pubspec["flutter_launcher_icons"]["adaptive_icon_background"] = ( + adaptive_icon_background + ) + + # check if pubspec changed + hash.update(Path(pubspec_origin_path).stat().st_mtime) + hash.update(pubspec["flutter_launcher_icons"]) + + # save pubspec.yaml + if hash.has_changed(): + if copy_ops: + self.update_status("[bold blue]Customizing app icons...") + for op in copy_ops: + if self.verbose > 0: + console.log( + f"Copying image {op[0]} to {op[1]}", style=verbose1_style + ) + shutil.copy(op[0], op[1]) + console.log(f"Customized app icons {self.emojis['checkmark']}") + + updated_pubspec = self.load_yaml(self.pubspec_path) + updated_pubspec["flutter_launcher_icons"] = pubspec[ + "flutter_launcher_icons" + ] + self.save_yaml(self.pubspec_path, updated_pubspec) + + self.update_status("[bold blue]Generating app icons...") + + # icons + icons_result = self.run( + [ + self.dart_exe, + "run", + "--suppress-analytics", + "flutter_launcher_icons", + ], + cwd=str(self.flutter_dir), + capture_output=self.verbose < 1, + ) + if icons_result.returncode != 0: + if icons_result.stdout: + console.log(icons_result.stdout, style=verbose1_style) + if icons_result.stderr: + console.log(icons_result.stderr, style=error_style) + self.cleanup(icons_result.returncode) + console.log(f"Generated app icons {self.emojis['checkmark']}") + + hash.commit() + + def customize_splash_images(self): + assert self.package_app_path + assert self.flutter_dir + assert self.options + assert self.get_pyproject + assert self.pubspec_path + assert self.build_dir + assert self.target_platform + + if self.target_platform not in ["web", "ipa", "apk", "aab"]: + return + + hash = HashStamp(self.build_dir / ".hash" / "splashes") + + pubspec_origin_path = f"{self.pubspec_path}.orig" + + pubspec = self.load_yaml(pubspec_origin_path) + + copy_ops = [] + self.assets_path = self.package_app_path.joinpath("assets") + if self.assets_path.exists(): + images_dir = "images" + images_path = self.flutter_dir.joinpath(images_dir) + images_path.mkdir(exist_ok=True) + + # copy icons + default_icon = self.find_platform_image( + self.assets_path, images_path, "icon", copy_ops, hash + ) + + # copy splash images + default_splash = self.find_platform_image( + self.assets_path, images_path, "splash", copy_ops, hash + ) + default_dark_splash = self.find_platform_image( + self.assets_path, images_path, "splash_dark", copy_ops, hash + ) + ios_splash = self.find_platform_image( + self.assets_path, images_path, "splash_ios", copy_ops, hash + ) + ios_dark_splash = self.find_platform_image( + self.assets_path, images_path, "splash_dark_ios", copy_ops, hash + ) + android_splash = self.find_platform_image( + self.assets_path, images_path, "splash_android", copy_ops, hash + ) + android_dark_splash = self.find_platform_image( + self.assets_path, images_path, "splash_dark_android", copy_ops, hash + ) + web_splash = self.find_platform_image( + self.assets_path, images_path, "splash_web", copy_ops, hash + ) + web_dark_splash = self.find_platform_image( + self.assets_path, images_path, "splash_dark_web", copy_ops, hash + ) + self.fallback_image( + pubspec, + "flutter_native_splash.image", + [default_splash, default_icon], + images_dir, + ) + self.fallback_image( + pubspec, + "flutter_native_splash.image_dark", + [default_dark_splash, default_splash, default_icon], + images_dir, + ) + self.fallback_image( + pubspec, + "flutter_native_splash.image_ios", + [ios_splash, default_splash, default_icon], + images_dir, + ) + self.fallback_image( + pubspec, + "flutter_native_splash.image_dark_ios", + [ + ios_dark_splash, + default_dark_splash, + ios_splash, + default_splash, + default_icon, + ], + images_dir, + ) + self.fallback_image( + pubspec, + "flutter_native_splash.image_android", + [android_splash, default_splash, default_icon], + images_dir, + ) + self.fallback_image( + pubspec, + "flutter_native_splash.android_12.image", + [android_splash, default_splash, default_icon], + images_dir, + ) + self.fallback_image( + pubspec, + "flutter_native_splash.image_dark_android", + [ + android_dark_splash, + default_dark_splash, + android_splash, + default_splash, + default_icon, + ], + images_dir, + ) + self.fallback_image( + pubspec, + "flutter_native_splash.android_12.image_dark", + [ + android_dark_splash, + default_dark_splash, + android_splash, + default_splash, + default_icon, + ], + images_dir, + ) + self.fallback_image( + pubspec, + "flutter_native_splash.image_web", + [web_splash, default_splash, default_icon], + images_dir, + ) + self.fallback_image( + pubspec, + "flutter_native_splash.image_dark_web", + [ + web_dark_splash, + default_dark_splash, + web_splash, + default_splash, + default_icon, + ], + images_dir, + ) + + # splash colors + splash_color = ( + self.options.splash_color + or self.get_pyproject(f"tool.flet.{self.config_platform}.splash.color") + or self.get_pyproject("tool.flet.splash.color") + ) + if splash_color: + pubspec["flutter_native_splash"]["color"] = splash_color + pubspec["flutter_native_splash"]["android_12"]["color"] = splash_color + + splash_dark_color = ( + self.options.splash_dark_color + or self.get_pyproject(f"tool.flet.{self.config_platform}.splash.dark_color") + or self.get_pyproject("tool.flet.splash.dark_color") + ) + if splash_dark_color: + pubspec["flutter_native_splash"]["color_dark"] = splash_dark_color + pubspec["flutter_native_splash"]["android_12"]["color_dark"] = ( + splash_dark_color + ) + + splash_icon_bgcolor = self.get_pyproject( + f"tool.flet.{self.config_platform}.splash.icon_bgcolor" + ) or self.get_pyproject("tool.flet.splash.icon_bgcolor") + + if splash_icon_bgcolor: + pubspec["flutter_native_splash"]["android_12"]["icon_background_color"] = ( + splash_icon_bgcolor + ) + + splash_icon_dark_bgcolor = self.get_pyproject( + f"tool.flet.{self.config_platform}.splash.icon_dark_bgcolor" + ) or self.get_pyproject("tool.flet.splash.icon_dark_bgcolor") + + if splash_icon_dark_bgcolor: + pubspec["flutter_native_splash"]["android_12"][ + "icon_background_color_dark" + ] = splash_icon_dark_bgcolor + + # enable/disable splashes + pubspec["flutter_native_splash"]["web"] = ( + not self.options.no_web_splash + if self.options.no_web_splash is not None + else ( + self.get_pyproject("tool.flet.splash.web") + if self.get_pyproject("tool.flet.splash.web") is not None + else True + ) + ) + pubspec["flutter_native_splash"]["ios"] = ( + not self.options.no_ios_splash + if self.options.no_ios_splash is not None + else ( + self.get_pyproject("tool.flet.splash.ios") + if self.get_pyproject("tool.flet.splash.ios") is not None + else True + ) + ) + pubspec["flutter_native_splash"]["android"] = ( + not self.options.no_android_splash + if self.options.no_android_splash is not None + else ( + self.get_pyproject("tool.flet.splash.android") + if self.get_pyproject("tool.flet.splash.android") is not None + else True + ) + ) + + # check if pubspec changed + hash.update(Path(pubspec_origin_path).stat().st_mtime) + hash.update(pubspec["flutter_native_splash"]) + + # save pubspec.yaml + if hash.has_changed(): + if copy_ops: + self.update_status("[bold blue]Customizing app splash images...") + for op in copy_ops: + if self.verbose > 0: + console.log( + f"Copying image {op[0]} to {op[1]}", style=verbose1_style + ) + shutil.copy(op[0], op[1]) + console.log(f"Customized app splash images {self.emojis['checkmark']}") + + updated_pubspec = self.load_yaml(self.pubspec_path) + updated_pubspec["flutter_native_splash"] = pubspec["flutter_native_splash"] + self.save_yaml(self.pubspec_path, updated_pubspec) + + # splash screens + self.update_status("[bold blue]Generating splash screens...") + splash_result = self.run( + [ + self.dart_exe, + "run", + "--suppress-analytics", + "flutter_native_splash:create", + ], + cwd=str(self.flutter_dir), + capture_output=self.verbose < 1, + ) + if splash_result.returncode != 0: + if splash_result.stdout: + console.log(splash_result.stdout, style=verbose1_style) + if splash_result.stderr: + console.log(splash_result.stderr, style=error_style) + self.cleanup(splash_result.returncode) + console.log(f"Generated splash screens {self.emojis['checkmark']}") + + hash.commit() + + def fallback_image(self, pubspec, yaml_path: str, images: list, images_dir: str): + d = pubspec + pp = yaml_path.split(".") + for p in pp[:-1]: + d = d[p] + for image in images: + if image: + d[pp[-1]] = f"{images_dir}/{image}" + return + + def package_python_app(self): + assert self.options + assert self.get_pyproject + assert self.python_app_path + assert self.package_app_path + assert self.build_dir + assert self.flutter_dir + assert self.flutter_packages_dir + assert self.flutter_packages_temp_dir + assert self.template_data + + hash = HashStamp(self.build_dir / ".hash" / "package") + + self.update_status("[bold blue]Packaging Python app...") + package_args = [ + self.dart_exe, + "run", + "--suppress-analytics", + "serious_python:main", + "package", + str(self.package_app_path), + "--platform", + self.package_platform, + ] + + if self.template_data["options"]["target_arch"]: + package_args.extend( + ["--arch"] + self.template_data["options"]["target_arch"] + ) + + package_env = {} + + # requirements + requirements_txt = self.python_app_path.joinpath("requirements.txt") + + toml_dependencies = ( + get_poetry_dependencies(self.get_pyproject("tool.poetry.dependencies")) + or get_project_dependencies(self.get_pyproject("project.dependencies")) + or [] + ) + + platform_dependencies = get_project_dependencies( + self.get_pyproject(f"tool.flet.{self.config_platform}.dependencies") + ) + if platform_dependencies: + toml_dependencies.extend(platform_dependencies) + + dev_packages_configured = False + if len(toml_dependencies) > 0: + dev_packages = ( + self.get_pyproject(f"tool.flet.{self.config_platform}.dev_packages") + or self.get_pyproject("tool.flet.dev_packages") + or [] + ) + if len(dev_packages) > 0: + for i in range(0, len(toml_dependencies)): + package_name = Requirement(toml_dependencies[i]).name + if package_name in dev_packages: + package_location = dev_packages[package_name] + dev_path = Path(package_location) + if not dev_path.is_absolute(): + dev_path = (self.python_app_path / dev_path).resolve() + if dev_path.exists(): + toml_dependencies[i] = f"{package_name} @ file://{dev_path}" + else: + toml_dependencies[i] = ( + f"{package_name} @ {package_location}" + ) + dev_packages_configured = True + if dev_packages_configured: + toml_dependencies.append("--no-cache-dir") + + for toml_dep in toml_dependencies: + package_args.extend(["-r", toml_dep]) + + elif requirements_txt.exists(): + if self.verbose > 1: + with open(requirements_txt, encoding="utf-8") as f: + reqs_txt_contents = f.read() + console.log( + f"Contents of requirements.txt: {reqs_txt_contents}", + style=verbose2_style, + ) + hash.update(reqs_txt_contents) + package_args.extend(["-r", "-r", "-r", str(requirements_txt)]) + else: + flet_version = ( + flet.version.version if flet.version.version else update_version() + ) + package_args.extend(["-r", f"flet=={flet_version}"]) + + # site-packages variable + if self.package_platform != "Pyodide": + package_env["SERIOUS_PYTHON_SITE_PACKAGES"] = str( + self.build_dir / "site-packages" + ) + + # flutter-packages variable + if self.flutter_packages_temp_dir.exists(): + shutil.rmtree(self.flutter_packages_temp_dir) + + package_env["SERIOUS_PYTHON_FLUTTER_PACKAGES"] = str( + self.flutter_packages_temp_dir + ) + + # exclude + exclude_list = ["build"] + + app_exclude = ( + self.options.exclude + or self.get_pyproject(f"tool.flet.{self.config_platform}.app.exclude") + or self.get_pyproject("tool.flet.app.exclude") + ) + if app_exclude: + exclude_list.extend(app_exclude) + + if self.target_platform == "web": + exclude_list.append("assets") + package_args.extend(["--exclude", ",".join(exclude_list)]) + + # source-packages + source_packages = ( + self.options.source_packages + or self.get_pyproject(f"tool.flet.{self.config_platform}.source_packages") + or self.get_pyproject("tool.flet.source_packages") + ) + if source_packages: + package_env["SERIOUS_PYTHON_ALLOW_SOURCE_DISTRIBUTIONS"] = ",".join( + source_packages + ) + + if self.get_bool_setting(self.options.compile_app, "compile.app", False): + package_args.append("--compile-app") + + if self.get_bool_setting( + self.options.compile_packages, "compile.packages", False + ): + package_args.append("--compile-packages") + + cleanup_app = self.get_bool_setting( + self.options.cleanup_app, "cleanup.app", False + ) + cleanup_packages = self.get_bool_setting( + self.options.cleanup_packages, "cleanup.packages", True + ) + + # TODO: should be deprecated + if self.get_bool_setting(None, "compile.cleanup", False): + cleanup_app = cleanup_packages = True + + if cleanup_app_files := ( + self.options.cleanup_app_files + or self.get_pyproject(f"tool.flet.{self.config_platform}.cleanup.app_files") + or self.get_pyproject("tool.flet.cleanup.app_files") + ): + package_args.extend(["--cleanup-app-files", ",".join(cleanup_app_files)]) + cleanup_app = True + + if cleanup_package_files := ( + self.options.cleanup_package_files + or self.get_pyproject( + f"tool.flet.{self.config_platform}.cleanup.package_files" + ) + or self.get_pyproject("tool.flet.cleanup.package_files") + ): + package_args.extend( + ["--cleanup-package-files", ",".join(cleanup_package_files)] + ) + cleanup_packages = True + + if cleanup_app: + package_args.append("--cleanup-app") + + if cleanup_packages: + package_args.append("--cleanup-packages") + + if self.verbose > 1: + package_args.append("--verbose") + + # check if site-packages installation could be skipped + for arg in package_args: + hash.update(arg) + + if not dev_packages_configured: + if not hash.has_changed(): + package_args.append("--skip-site-packages") + else: + if self.flutter_packages_dir.exists(): + shutil.rmtree(self.flutter_packages_dir, ignore_errors=True) + + package_result = self.run( + package_args, + cwd=str(self.flutter_dir), + env=package_env, + capture_output=self.verbose < 1, + ) + + if package_result.returncode != 0: + if package_result.stdout: + console.log(package_result.stdout, style=verbose1_style) + if package_result.stderr: + console.log(package_result.stderr, style=error_style) + self.cleanup(package_result.returncode) + + hash.commit() + + # make sure app/app.zip exists + app_zip_path = self.flutter_dir.joinpath("app", "app.zip") + if not os.path.exists(app_zip_path): + self.cleanup(1, "Flet app package app/app.zip was not created.") + + console.log(f"Packaged Python app {self.emojis['checkmark']}") + + def get_bool_setting(self, cli_option, pyproj_setting, default_value): + assert self.get_pyproject + return ( + cli_option + if cli_option is not None + else ( + self.get_pyproject(f"tool.flet.{self.config_platform}.{pyproj_setting}") + if self.get_pyproject( + f"tool.flet.{self.config_platform}.{pyproj_setting}" + ) + is not None + else ( + self.get_pyproject(f"tool.flet.{pyproj_setting}") + if self.get_pyproject(f"tool.flet.{pyproj_setting}") is not None + else default_value + ) + ) + ) + + def add_flutter_build_args(self, args: list[str]): + pass + + def flutter_build(self): + assert self.options + assert self.build_dir + assert self.get_pyproject + assert self.template_data + assert self.target_platform + + self.update_status( + f"[bold blue]Building [cyan]" + f"{self.platforms[self.target_platform]['status_text']}[/cyan]..." + ) + + # flutter build + build_args = [self.flutter_exe] + self.add_flutter_build_args(build_args) + build_args.extend( + [ + "--no-version-check", + "--suppress-analytics", + ] + ) + + build_env = {} + + # site-packages variable + if self.package_platform != "Pyodide": + build_env["SERIOUS_PYTHON_SITE_PACKAGES"] = str( + self.build_dir / "site-packages" + ) + + if self.package_platform == "Pyodide" and not self.template_data["no_wasm"]: + build_args.append("--wasm") + + android_signing_key_store = ( + self.options.android_signing_key_store + or self.get_pyproject("tool.flet.android.signing.key_store") + ) + if android_signing_key_store: + build_env["FLET_ANDROID_SIGNING_KEY_STORE"] = android_signing_key_store + + key_store_password = ( + self.options.android_signing_key_store_password + or os.getenv("FLET_ANDROID_SIGNING_KEY_STORE_PASSWORD") + ) + key_password = self.options.android_signing_key_password or os.getenv( + "FLET_ANDROID_SIGNING_KEY_PASSWORD" + ) + if key_store_password or key_password: + build_env["FLET_ANDROID_SIGNING_KEY_STORE_PASSWORD"] = ( + key_store_password if key_store_password else key_password + ) + build_env["FLET_ANDROID_SIGNING_KEY_PASSWORD"] = ( + key_password if key_password else key_store_password + ) + + android_signing_key_alias = ( + self.options.android_signing_key_alias + or self.get_pyproject("tool.flet.android.signing.key_alias") + ) + if android_signing_key_alias: + build_env["FLET_ANDROID_SIGNING_KEY_ALIAS"] = android_signing_key_alias + + if self.options.flutter_build_args: + for flutter_build_arg_arr in self.options.flutter_build_args: + build_args.extend(flutter_build_arg_arr) + + if self.verbose > 1: + build_args.append("--verbose") + + build_result = self.run( + build_args, + cwd=str(self.flutter_dir), + env=build_env, + capture_output=self.verbose < 1, + ) + + if ( + build_result.returncode != 0 + or "Encountered error while creating the IPA" in str(build_result.stderr) + ): + if build_result.stdout: + console.log(build_result.stdout, style=verbose1_style) + if build_result.stderr: + console.log(build_result.stderr, style=error_style) + self.cleanup(build_result.returncode if build_result.returncode else 1) + console.log( + f"Built [cyan]{self.platforms[self.target_platform]['status_text']}" + f"[/cyan] {self.emojis['checkmark']}", + ) + + def copy_build_output(self): + assert self.template_data + assert self.options + assert self.flutter_dir + assert self.out_dir + assert self.assets_path + assert self.target_platform + + self.update_status( + f"[bold blue]Copying build to [cyan]{self.rel_out_dir}[/cyan] directory...", + ) + arch = platform.machine().lower() + if arch in {"x86_64", "amd64"}: + arch = "x64" + elif arch in {"arm64", "aarch64"}: + arch = "arm64" + + def make_ignore_fn(out_dir, out_glob): + def ignore(path, names): + if path == out_dir and out_glob != "*": + return [f for f in os.listdir(path) if f != out_glob] + return [] + + return ignore + + for build_output in self.platforms[self.target_platform]["outputs"]: + build_output_dir = ( + str(self.flutter_dir.joinpath(build_output)) + .replace("{arch}", arch) + .replace("{project_name}", self.template_data["project_name"]) + .replace("{product_name}", self.template_data["product_name"]) + ) + + if self.verbose > 0: + console.log( + "Copying build output from: " + build_output_dir, + style=verbose1_style, + ) + + build_output_glob = os.path.basename(build_output_dir) + build_output_dir = os.path.dirname(build_output_dir) + if not os.path.exists(build_output_dir): + continue + + if self.out_dir.exists(): + shutil.rmtree(str(self.out_dir)) + self.out_dir.mkdir(parents=True, exist_ok=True) + + # copy build result to out_dir + copy_tree( + build_output_dir, + str(self.out_dir), + ignore=make_ignore_fn(build_output_dir, build_output_glob), + ) + + if self.target_platform == "web" and self.assets_path.exists(): + # copy `assets` directory contents to the output directory + copy_tree(str(self.assets_path), str(self.out_dir)) + + console.log( + f"Copied build to [cyan]{self.rel_out_dir}[/cyan] " + f"directory {self.emojis['checkmark']}" + ) + + def find_platform_image( + self, + src_path: Path, + dest_path: Path, + image_name: str, + copy_ops: list, + hash: HashStamp, + ): + images = glob.glob(str(src_path.joinpath(f"{image_name}.*"))) + if len(images) > 0: + if self.verbose > 0: + console.log( + f'Found "{image_name}" image at {images[0]}', style=verbose1_style + ) + copy_ops.append((images[0], dest_path)) + hash.update(images[0]) + hash.update(Path(images[0]).stat().st_mtime) + return Path(images[0]).name + return None + + def find_flutter_batch(self, exe_filename: str): + batch_path = shutil.which(exe_filename) + if not batch_path: + return None + if is_windows() and batch_path.endswith(".file"): + return batch_path.replace(".file", ".bat") + return batch_path + + def run(self, args, cwd, env: Optional[dict] = None, capture_output=True): + if self.verbose > 0: + console.log(f"Run subprocess: {args}", style=verbose1_style) + + return processes.run( + args, + cwd, + env={**self.env, **env} if env else self.env, + capture_output=capture_output, + log=self.log_stdout, + ) + + def cleanup(self, exit_code: int, message: Optional[str] = None): + if exit_code == 0: + msg = message or f"Success! {self.emojis['success']}" + self.live.update(Panel(msg), refresh=True) + else: + msg = ( + message + if message is not None + else "Error building Flet app - see the log of failed command above." + ) + + # windows has been reported to raise encoding errors + # when running `flutter doctor` + # so skip running `flutter doctor` if no_rich_output is True + # and platform is Windows + if not ( + (self.no_rich_output and self.current_platform == "Windows") + or self.skip_flutter_doctor + ): + status = console.status( + "[bold blue]Running Flutter doctor...", + spinner="bouncingBall", + ) + self.live.update( + Group(Panel(msg, style=error_style), status), refresh=True + ) + self.run_flutter_doctor() + self.live.update(Panel(msg, style=error_style), refresh=True) + + sys.exit(exit_code) + + def run_flutter_doctor(self): + flutter_doctor = self.run( + [self.flutter_exe, "doctor", "--no-version-check", "--suppress-analytics"], + cwd=os.getcwd(), + capture_output=True, + ) + if flutter_doctor.returncode == 0 and flutter_doctor.stdout: + console.log(flutter_doctor.stdout, style=verbose1_style) + + def update_status(self, status): + if self.no_rich_output: + console.log(status) + else: + self.status.update(status) + + def log_stdout(self, message): + if self.verbose > 0: + console.log( + message, + end="", + style=verbose2_style, + markup=False, + ) + + def load_yaml(self, path): + with open(str(path), encoding="utf-8") as f: + return yaml.safe_load(f) + + def save_yaml(self, path, doc): + with open(str(path), "w", encoding="utf-8") as f: + yaml.dump(doc, f) diff --git a/sdk/python/packages/flet/docs/cli/flet-debug.md b/sdk/python/packages/flet/docs/cli/flet-debug.md new file mode 100644 index 000000000..1ad5bc5ca --- /dev/null +++ b/sdk/python/packages/flet/docs/cli/flet-debug.md @@ -0,0 +1,5 @@ +--- +title: flet debug +--- + +{{ flet_cli_as_markdown("debug") }} diff --git a/sdk/python/packages/flet/mkdocs.yml b/sdk/python/packages/flet/mkdocs.yml index 245fb46a4..0a322cc63 100644 --- a/sdk/python/packages/flet/mkdocs.yml +++ b/sdk/python/packages/flet/mkdocs.yml @@ -464,6 +464,7 @@ nav: - Overview: cli/index.md - flet build: cli/flet-build.md - flet create: cli/flet-create.md + - flet debug: cli/flet-debug.md - flet doctor: cli/flet-doctor.md - flet pack: cli/flet-pack.md - flet publish: cli/flet-publish.md From fd0ebbe49d2e49a5218ac36bfa5292203822e32c Mon Sep 17 00:00:00 2001 From: Feodor Fitsner Date: Wed, 26 Nov 2025 09:53:35 -0800 Subject: [PATCH 02/22] Refactor Flutter command execution in CLI commands Replaces 'flutter_build' with 'run_flutter' in build and debug commands, consolidating command execution logic. Updates method names for clarity and moves status updates and logging to command-specific implementations. Improves code maintainability and consistency across build and debug workflows. --- .../flet-cli/src/flet_cli/commands/build.py | 20 +++++++++-- .../flet-cli/src/flet_cli/commands/debug.py | 33 ++++++++----------- .../src/flet_cli/commands/flutter_base.py | 18 ++++------ 3 files changed, 38 insertions(+), 33 deletions(-) diff --git a/sdk/python/packages/flet-cli/src/flet_cli/commands/build.py b/sdk/python/packages/flet-cli/src/flet_cli/commands/build.py index 4abb3bbdf..3ec9d3b7b 100644 --- a/sdk/python/packages/flet-cli/src/flet_cli/commands/build.py +++ b/sdk/python/packages/flet-cli/src/flet_cli/commands/build.py @@ -57,7 +57,7 @@ def handle(self, options: argparse.Namespace) -> None: self.update_flutter_dependencies() self.customize_icons() self.customize_splash_images() - self.flutter_build() + self.run_flutter() self.copy_build_output() self.cleanup( @@ -77,7 +77,7 @@ def handle(self, options: argparse.Namespace) -> None: ), ) - def add_flutter_build_args(self, args: list[str]): + def add_flutter_command_args(self, args: list[str]): assert self.options assert self.build_dir assert self.get_pyproject @@ -122,3 +122,19 @@ def add_flutter_build_args(self, args: list[str]): or [] ): args.append(arg) + + def run_flutter(self): + assert self.platforms + assert self.target_platform + + self.update_status( + f"[bold blue]Building [cyan]" + f"{self.platforms[self.target_platform]['status_text']}[/cyan]..." + ) + + self._run_flutter_command() + + console.log( + f"Built [cyan]{self.platforms[self.target_platform]['status_text']}" + f"[/cyan] {self.emojis['checkmark']}", + ) diff --git a/sdk/python/packages/flet-cli/src/flet_cli/commands/debug.py b/sdk/python/packages/flet-cli/src/flet_cli/commands/debug.py index a12d2fbed..8b3ced131 100644 --- a/sdk/python/packages/flet-cli/src/flet_cli/commands/debug.py +++ b/sdk/python/packages/flet-cli/src/flet_cli/commands/debug.py @@ -1,4 +1,5 @@ import argparse +import contextlib import os import platform @@ -98,24 +99,8 @@ def handle(self, options: argparse.Namespace) -> None: self.update_flutter_dependencies() self.customize_icons() self.customize_splash_images() - self.flutter_build() - - self.cleanup( - 0, - message=( - f"Successfully built your [cyan]" - f"{self.platforms[self.target_platform]['status_text']}" - f"[/cyan]! {self.emojis['success']} " - f"Find it in [cyan]{self.rel_out_dir}[/cyan] directory. " - f"{self.emojis['directory']}" - + ( - "\nRun [cyan]flet serve[/cyan] command to start a web server " - "with your app. " - if self.target_platform == "web" - else "" - ) - ), - ) + self.run_flutter() + self.cleanup(0, message=("Debug session ended.")) def check_device_id(self): if self.device_id is None and self.debug_platform in [ @@ -130,10 +115,20 @@ def check_device_id(self): "Use --show-devices option to list connected devices.", ) - def add_flutter_build_args(self, args: list[str]): + def add_flutter_command_args(self, args: list[str]): assert self.device_id args.extend(["run", "-d", self.device_id]) + def run_flutter(self): + assert self.platforms + assert self.target_platform + self.update_status( + f"[bold blue]Running the app on [cyan]{self.debug_platform}[/cyan]..." + ) + + with contextlib.suppress(KeyboardInterrupt): + self._run_flutter_command() + def run_flutter_devices(self): self.update_status("[bold blue]Checking connected devices...") flutter_devices = self.run( diff --git a/sdk/python/packages/flet-cli/src/flet_cli/commands/flutter_base.py b/sdk/python/packages/flet-cli/src/flet_cli/commands/flutter_base.py index e7ec58533..dd3760f13 100644 --- a/sdk/python/packages/flet-cli/src/flet_cli/commands/flutter_base.py +++ b/sdk/python/packages/flet-cli/src/flet_cli/commands/flutter_base.py @@ -1939,24 +1939,22 @@ def get_bool_setting(self, cli_option, pyproj_setting, default_value): ) ) - def add_flutter_build_args(self, args: list[str]): + def add_flutter_command_args(self, args: list[str]): pass - def flutter_build(self): + def run_flutter(self): + self._run_flutter_command() + + def _run_flutter_command(self): assert self.options assert self.build_dir assert self.get_pyproject assert self.template_data assert self.target_platform - self.update_status( - f"[bold blue]Building [cyan]" - f"{self.platforms[self.target_platform]['status_text']}[/cyan]..." - ) - # flutter build build_args = [self.flutter_exe] - self.add_flutter_build_args(build_args) + self.add_flutter_command_args(build_args) build_args.extend( [ "--no-version-check", @@ -2027,10 +2025,6 @@ def flutter_build(self): if build_result.stderr: console.log(build_result.stderr, style=error_style) self.cleanup(build_result.returncode if build_result.returncode else 1) - console.log( - f"Built [cyan]{self.platforms[self.target_platform]['status_text']}" - f"[/cyan] {self.emojis['checkmark']}", - ) def copy_build_output(self): assert self.template_data From 398d5f71a2af5a1a6f3704ae489a540df7e027c3 Mon Sep 17 00:00:00 2001 From: Feodor Fitsner Date: Wed, 26 Nov 2025 10:05:59 -0800 Subject: [PATCH 03/22] Add release mode option to debug command Introduces a --release flag to the debug command, allowing users to build the app in release mode. Also updates argument handling to support the new option. --- .../flet-cli/src/flet_cli/commands/debug.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/sdk/python/packages/flet-cli/src/flet_cli/commands/debug.py b/sdk/python/packages/flet-cli/src/flet_cli/commands/debug.py index 8b3ced131..abd6de908 100644 --- a/sdk/python/packages/flet-cli/src/flet_cli/commands/debug.py +++ b/sdk/python/packages/flet-cli/src/flet_cli/commands/debug.py @@ -50,6 +50,13 @@ def add_arguments(self, parser: argparse.ArgumentParser) -> None: default=False, help="Show connected devices for iOS and Android builds.", ) + parser.add_argument( + "--release", + dest="release", + action="store_true", + default=False, + help="Build the app in release mode.", + ) parser.add_argument( "--route", type=str, @@ -119,6 +126,16 @@ def add_flutter_command_args(self, args: list[str]): assert self.device_id args.extend(["run", "-d", self.device_id]) + if self.options: + if self.options.release: + args.append("--release") + if self.options.route and self.debug_platform in [ + "web", + "ios", + "android", + ]: + args.extend(["--route", self.options.route]) + def run_flutter(self): assert self.platforms assert self.target_platform From 4a443a640e32607fc044561004fe68726b5ce755 Mon Sep 17 00:00:00 2001 From: Feodor Fitsner Date: Wed, 26 Nov 2025 10:34:46 -0800 Subject: [PATCH 04/22] Remove default Impeller meta-data from Android config Eliminates the pre-configured 'io.flutter.embedding.android.EnableImpeller' meta-data from both the CLI implementation and documentation, reflecting updated defaults for Android publishing. --- .../packages/flet-cli/src/flet_cli/commands/flutter_base.py | 2 +- sdk/python/packages/flet/docs/publish/android.md | 4 ---- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/sdk/python/packages/flet-cli/src/flet_cli/commands/flutter_base.py b/sdk/python/packages/flet-cli/src/flet_cli/commands/flutter_base.py index dd3760f13..7694a7011 100644 --- a/sdk/python/packages/flet-cli/src/flet_cli/commands/flutter_base.py +++ b/sdk/python/packages/flet-cli/src/flet_cli/commands/flutter_base.py @@ -908,7 +908,7 @@ def setup_template_data(self): "android.software.leanback": False, "android.hardware.touchscreen": False, } - android_meta_data = {"io.flutter.embedding.android.EnableImpeller": "false"} + android_meta_data = {} # merge values from "--permissions" arg: for p in ( diff --git a/sdk/python/packages/flet/docs/publish/android.md b/sdk/python/packages/flet/docs/publish/android.md index 467687b06..b276a1b1f 100644 --- a/sdk/python/packages/flet/docs/publish/android.md +++ b/sdk/python/packages/flet/docs/publish/android.md @@ -375,10 +375,6 @@ And it will be translated accordingly into this in the `AndroidManifest.xml`: ``` -Below are default/pre-configured meta-data: - -- `"io.flutter.embedding.android.EnableImpeller" = false` - ### Features A hardware or software feature that is used by the application. From c34f553b33fcc09bf7e80805b5e2ef0331b579b9 Mon Sep 17 00:00:00 2001 From: Feodor Fitsner Date: Wed, 26 Nov 2025 12:27:15 -0800 Subject: [PATCH 05/22] Refactor build command to use BaseBuildCommand Moved shared build logic from build.py to a new build_base.py, introducing BaseBuildCommand for common build functionality. Updated build.py to inherit from BaseBuildCommand instead of BaseFlutterCommand, and adjusted imports accordingly. This improves code organization and reuse for build-related CLI commands. --- .../flet-cli/src/flet_cli/commands/build.py | 4 +- .../src/flet_cli/commands/build_base.py | 2035 +++++++++++++++++ .../flet-cli/src/flet_cli/commands/debug.py | 4 +- .../src/flet_cli/commands/flutter_base.py | 1910 +--------------- 4 files changed, 2043 insertions(+), 1910 deletions(-) create mode 100644 sdk/python/packages/flet-cli/src/flet_cli/commands/build_base.py diff --git a/sdk/python/packages/flet-cli/src/flet_cli/commands/build.py b/sdk/python/packages/flet-cli/src/flet_cli/commands/build.py index 3ec9d3b7b..c7362ab5c 100644 --- a/sdk/python/packages/flet-cli/src/flet_cli/commands/build.py +++ b/sdk/python/packages/flet-cli/src/flet_cli/commands/build.py @@ -4,10 +4,10 @@ from rich.live import Live from rich.progress import Progress -from flet_cli.commands.flutter_base import BaseFlutterCommand, console +from flet_cli.commands.build_base import BaseBuildCommand, console -class Command(BaseFlutterCommand): +class Command(BaseBuildCommand): """ Build a Flet Python app into a platform-specific executable or installable bundle. It supports building for desktop (macOS, Linux, Windows), web, diff --git a/sdk/python/packages/flet-cli/src/flet_cli/commands/build_base.py b/sdk/python/packages/flet-cli/src/flet_cli/commands/build_base.py new file mode 100644 index 000000000..f1cea628f --- /dev/null +++ b/sdk/python/packages/flet-cli/src/flet_cli/commands/build_base.py @@ -0,0 +1,2035 @@ +import argparse +import glob +import os +import platform +import shutil +import sys +from pathlib import Path +from typing import Optional, cast + +import yaml +from packaging import version +from packaging.requirements import Requirement +from rich.console import Group +from rich.panel import Panel +from rich.table import Column, Table + +import flet.version +import flet_cli.utils.processes as processes +from flet.utils import copy_tree, is_windows, slugify +from flet.utils.platform_utils import get_bool_env_var +from flet.version import update_version +from flet_cli.commands.flutter_base import ( + BaseFlutterCommand, + console, + error_style, + no_rich_output, + verbose1_style, + verbose2_style, + warning_style, +) +from flet_cli.utils.hash_stamp import HashStamp +from flet_cli.utils.merge import merge_dict +from flet_cli.utils.project_dependencies import ( + get_poetry_dependencies, + get_project_dependencies, +) +from flet_cli.utils.pyproject_toml import load_pyproject_toml + +PYODIDE_ROOT_URL = "https://cdn.jsdelivr.net/pyodide/v0.27.7/full" +DEFAULT_TEMPLATE_URL = "gh:flet-dev/flet-build-template" + + +class BaseBuildCommand(BaseFlutterCommand): + """ + A base build-related CLI command. + """ + + def __init__(self, parser: argparse.ArgumentParser) -> None: + super().__init__(parser) + + self.env = {} + self.pubspec_path = None + self.rel_out_dir = None + self.assets_path = None + self.target_platform = None + self.debug_platform = None + self.package_platform = None + self.config_platform = None + self.flutter_dependencies = {} + self.package_app_path = None + self.options = None + self.template_data = None + self.python_module_filename = None + self.out_dir = None + self.python_module_name = None + self.get_pyproject = None + self.python_app_path = None + self.emojis = {} + self.dart_exe = None + self.verbose = False + self.build_dir = None + self.flutter_dir: Optional[Path] = None + self.flutter_packages_dir = None + self.flutter_packages_temp_dir = None + self.flutter_exe = None + self.skip_flutter_doctor = get_bool_env_var("FLET_CLI_SKIP_FLUTTER_DOCTOR") + self.no_rich_output = no_rich_output + self.current_platform = platform.system() + self.platforms = { + "windows": { + "package_platform": "Windows", + "config_platform": "windows", + "flutter_build_command": "windows", + "status_text": "Windows app", + "outputs": ["build/windows/x64/runner/Release/*"], + "dist": "windows", + "can_be_run_on": ["Windows"], + }, + "macos": { + "package_platform": "Darwin", + "config_platform": "macos", + "flutter_build_command": "macos", + "status_text": "macOS bundle", + "outputs": ["build/macos/Build/Products/Release/{product_name}.app"], + "dist": "macos", + "can_be_run_on": ["Darwin"], + }, + "linux": { + "package_platform": "Linux", + "config_platform": "linux", + "flutter_build_command": "linux", + "status_text": "app for Linux", + "outputs": ["build/linux/{arch}/release/bundle/*"], + "dist": "linux", + "can_be_run_on": ["Linux"], + }, + "web": { + "package_platform": "Pyodide", + "config_platform": "web", + "flutter_build_command": "web", + "status_text": "web app", + "outputs": ["build/web/*"], + "dist": "web", + "can_be_run_on": ["Darwin", "Windows", "Linux"], + }, + "apk": { + "package_platform": "Android", + "config_platform": "android", + "flutter_build_command": "apk", + "status_text": ".apk for Android", + "outputs": ["build/app/outputs/flutter-apk/*"], + "dist": "apk", + "can_be_run_on": ["Darwin", "Windows", "Linux"], + }, + "aab": { + "package_platform": "Android", + "config_platform": "android", + "flutter_build_command": "appbundle", + "status_text": ".aab bundle for Android", + "outputs": ["build/app/outputs/bundle/release/*"], + "dist": "aab", + "can_be_run_on": ["Darwin", "Windows", "Linux"], + }, + "ipa": { + "package_platform": "iOS", + "config_platform": "ios", + "flutter_build_command": "ipa", + "status_text": ".ipa bundle for iOS", + "outputs": ["build/ios/archive/*", "build/ios/ipa/*"], + "dist": "ipa", + "can_be_run_on": ["Darwin"], + }, + } + + self.cross_platform_permissions = { + "location": { + "info_plist": { + "NSLocationWhenInUseUsageDescription": "This app uses location " + "service when in use.", + "NSLocationAlwaysAndWhenInUseUsageDescription": "This app uses " + "location service.", + }, + "macos_entitlements": { + "com.apple.security.personal-information.location": True + }, + "android_permissions": { + "android.permission.ACCESS_FINE_LOCATION": True, + "android.permission.ACCESS_COARSE_LOCATION": True, + "android.permission.ACCESS_BACKGROUND_LOCATION": True, + }, + "android_features": { + "android.hardware.location.network": False, + "android.hardware.location.gps": False, + }, + }, + "camera": { + "info_plist": { + "NSCameraUsageDescription": "This app uses the camera to capture " + "photos and videos." + }, + "macos_entitlements": {"com.apple.security.device.camera": True}, + "android_permissions": {"android.permission.CAMERA": True}, + "android_features": { + "android.hardware.camera": False, + "android.hardware.camera.any": False, + "android.hardware.camera.front": False, + "android.hardware.camera.external": False, + "android.hardware.camera.autofocus": False, + }, + }, + "microphone": { + "info_plist": { + "NSMicrophoneUsageDescription": "This app uses microphone to " + "record sounds.", + }, + "macos_entitlements": {"com.apple.security.device.audio-input": True}, + "android_permissions": { + "android.permission.RECORD_AUDIO": True, + "android.permission.WRITE_EXTERNAL_STORAGE": True, + "android.permission.READ_EXTERNAL_STORAGE": True, + }, + "android_features": {}, + }, + "photo_library": { + "info_plist": { + "NSPhotoLibraryUsageDescription": "This app saves photos and " + "videos to the photo library." + }, + "macos_entitlements": { + "com.apple.security.personal-information.photos-library": True + }, + "android_permissions": { + "android.permission.READ_MEDIA_VISUAL_USER_SELECTED": True + }, + "android_features": {}, + }, + } + + # create and display build-platform-matrix table + self.platform_matrix_table = Table( + Column("Command", style="cyan", justify="left"), + Column("Platform", style="magenta", justify="center"), + title="Build Platform Matrix", + header_style="bold", + show_lines=True, + ) + for p, info in self.platforms.items(): + self.platform_matrix_table.add_row( + "flet build " + p, + ", ".join(info["can_be_run_on"]).replace("Darwin", "macOS"), + ) + + def add_arguments(self, parser: argparse.ArgumentParser) -> None: + parser.add_argument( + "python_app_path", + type=str, + nargs="?", + default=".", + help="Path to a directory with a Flet Python program", + ) + parser.add_argument( + "--arch", + dest="target_arch", + nargs="+", + default=[], + help="Build for specific CPU architectures " + "(used in macOS and Android builds only). Example: `--arch arm64 x64`", + ) + parser.add_argument( + "--exclude", + dest="exclude", + nargs="+", + default=[], + help="Files and/or directories to exclude from the package", + ) + parser.add_argument( + "--clear-cache", + dest="clear_cache", + action="store_true", + default=None, + help="Remove any existing build cache before starting the build process", + ) + parser.add_argument( + "--project", + dest="project_name", + required=False, + help="Project name for the executable/bundle. " + "It is used in metadata and bundle IDs", + ) + parser.add_argument( + "--description", + dest="description", + required=False, + help="Short description of the application", + ) + parser.add_argument( + "--product", + dest="product_name", + required=False, + help="Display name of the app that is shown in window titles " + "and about app dialogs", + ) + parser.add_argument( + "--org", + dest="org_name", + required=False, + help="Organization name in reverse domain name notation, " + "e.g. `com.mycompany`, combined with project name and " + "used in bundle IDs and signing", + ) + parser.add_argument( + "--bundle-id", + dest="bundle_id", + required=False, + help="Bundle ID for the application, e.g. `com.mycompany.app-name`. " + "It is used as an iOS, Android, macOS and Linux bundle ID", + ) + parser.add_argument( + "--company", + dest="company_name", + required=False, + help="Company name to display in about app dialogs", + ) + parser.add_argument( + "--copyright", + dest="copyright", + required=False, + help="Copyright text to display in about app dialogs", + ) + parser.add_argument( + "--android-adaptive-icon-background", + dest="android_adaptive_icon_background", + required=False, + help="The color to be used to fill out the background of " + "Android adaptive icons", + ) + parser.add_argument( + "--splash-color", + dest="splash_color", + required=False, + help="Background color of app splash screen on iOS, Android and web", + ) + parser.add_argument( + "--splash-dark-color", + dest="splash_dark_color", + required=False, + help="Background color in dark mode of app splash screen on " + "iOS, Android and web", + ) + parser.add_argument( + "--no-web-splash", + dest="no_web_splash", + action="store_true", + default=None, + help="Disable splash screen on web platform", + ) + parser.add_argument( + "--no-ios-splash", + dest="no_ios_splash", + action="store_true", + default=None, + help="Disable splash screen on iOS platform", + ) + parser.add_argument( + "--no-android-splash", + dest="no_android_splash", + action="store_true", + default=None, + help="Disable splash screen on Android platform", + ) + parser.add_argument( + "--ios-team-id", + dest="ios_team_id", + type=str, + help="Apple developer team ID for signing iOS app bundle (ipa only)", + required=False, + ) + parser.add_argument( + "--ios-export-method", + dest="ios_export_method", + type=str, + required=False, + help="Export method for iOS app bundle (default: debugging)", + ) + parser.add_argument( + "--ios-provisioning-profile", + dest="ios_provisioning_profile", + type=str, + required=False, + help="Provisioning profile name or UUID that should be used to sign and " + "export iOS app bundle", + ) + parser.add_argument( + "--ios-signing-certificate", + dest="ios_signing_certificate", + type=str, + required=False, + help="Signing certificate name, SHA-1 hash, or automatic selector to use " + "for signing iOS app bundle", + ) + parser.add_argument( + "--base-url", + dest="base_url", + type=str, + help="Base URL from which the app is served (web only)", + ) + parser.add_argument( + "--web-renderer", + dest="web_renderer", + choices=["auto", "canvaskit", "skwasm"], + help="Flutter web renderer to use (web only)", + ) + parser.add_argument( + "--route-url-strategy", + dest="route_url_strategy", + choices=["path", "hash"], + help="Base URL path to serve the app from. " + "Useful if the app is hosted in a subdirectory (web only)", + ) + parser.add_argument( + "--pwa-background-color", + dest="pwa_background_color", + required=False, + help="Initial background color for your web app (web only)", + ) + parser.add_argument( + "--pwa-theme-color", + dest="pwa_theme_color", + required=False, + help="Default color for your web app's user interface (web only)", + ) + parser.add_argument( + "--no-wasm", + dest="no_wasm", + action="store_true", + default=False, + help="Disable WASM target for web build (web only)", + ) + parser.add_argument( + "--no-cdn", + dest="no_cdn", + action="store_true", + default=False, + help="Disable loading of CanvasKit, Pyodide and fonts from CDN", + ) + parser.add_argument( + "--split-per-abi", + dest="split_per_abi", + action="store_true", + default=None, + help="Split the APKs per ABIs (Android only)", + ) + parser.add_argument( + "--compile-app", + dest="compile_app", + action="store_true", + default=None, + help="Pre-compile app's `.py` files to `.pyc`", + ) + parser.add_argument( + "--compile-packages", + dest="compile_packages", + action="store_true", + default=None, + help="Pre-compile site packages' `.py` files to `.pyc`", + ) + parser.add_argument( + "--cleanup-app", + dest="cleanup_app", + action="store_true", + default=None, + help="Remove unnecessary app files upon packaging", + ) + parser.add_argument( + "--cleanup-app-files", + dest="cleanup_app_files", + action="append", + nargs="*", + help="The list of globs to delete extra app files and directories", + ) + parser.add_argument( + "--cleanup-packages", + dest="cleanup_packages", + action="store_true", + default=None, + help="Remove unnecessary package files upon packaging", + ) + parser.add_argument( + "--cleanup-package-files", + dest="cleanup_package_files", + action="append", + nargs="*", + help="The list of globs to delete extra package files and directories", + ) + parser.add_argument( + "--flutter-build-args", + dest="flutter_build_args", + action="append", + nargs="*", + help="Additional arguments for flutter build command", + ) + parser.add_argument( + "--source-packages", + dest="source_packages", + nargs="+", + default=[], + help="The list of Python packages to install from source distributions", + ) + parser.add_argument( + "--info-plist", + dest="info_plist", + nargs="+", + default=[], + help="The list of `=|True|False` pairs to add to Info.plist " + "for macOS and iOS builds (macos and ipa only)", + ) + parser.add_argument( + "--macos-entitlements", + dest="macos_entitlements", + nargs="+", + default=[], + help="The list of `=|True|False` entitlements for " + "macOS builds (macos only)", + ) + parser.add_argument( + "--android-features", + dest="android_features", + nargs="+", + default=[], + help="The list of `=True|False` features to add to " + "AndroidManifest.xml for Android builds (android only)", + ) + parser.add_argument( + "--android-permissions", + dest="android_permissions", + nargs="+", + default=[], + help="The list of `=True|False` permissions to add to " + "AndroidManifest.xml for Android builds (android only)", + ) + parser.add_argument( + "--android-meta-data", + dest="android_meta_data", + nargs="+", + default=[], + help="The list of `=` app meta-data entries to add to " + "AndroidManifest.xml for Android builds (android only)", + ) + parser.add_argument( + "--permissions", + dest="permissions", + nargs="+", + default=[], + choices=["location", "camera", "microphone", "photo_library"], + help="The list of pre-defined cross-platform permissions for iOS, Android " + "and macOS builds", + ) + parser.add_argument( + "--deep-linking-scheme", + dest="deep_linking_scheme", + help="Deep linking URL scheme to configure for iOS and Android builds, " + "i.g. `https` or `myapp`", + ) + parser.add_argument( + "--deep-linking-host", + dest="deep_linking_host", + help="Deep linking URL host for iOS and Android builds", + ) + parser.add_argument( + "--android-signing-key-store", + dest="android_signing_key_store", + help="path to an upload keystore `.jks` file for Android apps", + ) + parser.add_argument( + "--android-signing-key-store-password", + dest="android_signing_key_store_password", + help="Android signing store password", + ) + parser.add_argument( + "--android-signing-key-password", + dest="android_signing_key_password", + help="Android signing key password", + ) + parser.add_argument( + "--android-signing-key-alias", + dest="android_signing_key_alias", + default="upload", + help="Android signing key alias (default: upload)", + ) + parser.add_argument( + "--build-number", + dest="build_number", + type=int, + help="Build number - an identifier used as an internal version number", + ) + parser.add_argument( + "--build-version", + dest="build_version", + help="Build version - a `x.y.z` string used as the version number " + "shown to users", + ) + parser.add_argument( + "--module-name", + dest="module_name", + help="Python module name with an app entry point", + ) + parser.add_argument( + "--template", + dest="template", + type=str, + help="Directory containing Flutter bootstrap template, or a URL " + "to a git repository template", + ) + parser.add_argument( + "--template-dir", + dest="template_dir", + type=str, + help="Relative path to a Flutter bootstrap template in a repository", + ) + parser.add_argument( + "--template-ref", + dest="template_ref", + type=str, + help="The branch, tag or commit ID to checkout after cloning " + "the repository with Flutter bootstrap template", + ) + parser.add_argument( + "--show-platform-matrix", + action="store_true", + default=False, + help="Display the build platform matrix in a table, then exit", + ) + super().add_arguments(parser) + + def handle(self, options: argparse.Namespace) -> None: + super().handle(options) + if "target_platform" in self.options: + self.target_platform = self.options.target_platform + + def initialize_build(self): + assert self.options + assert self.target_platform + super().initialize_build() + + self.python_app_path = Path(self.options.python_app_path).resolve() + + self.package_platform = self.platforms[self.target_platform]["package_platform"] + self.config_platform = self.platforms[self.target_platform]["config_platform"] + + if not ( + os.path.exists(self.python_app_path) or os.path.isdir(self.python_app_path) + ): + self.cleanup( + 1, + f"Path to Flet app does not exist or is not a directory: " + f"{self.python_app_path}", + ) + + self.rel_out_dir = self.options.output_dir or os.path.join( + "build", self.platforms[self.target_platform]["dist"] + ) + + self.build_dir = self.python_app_path.joinpath("build") + self.flutter_dir = self.build_dir.joinpath("flutter") + self.flutter_packages_dir = self.build_dir.joinpath("flutter-packages") + self.flutter_packages_temp_dir = self.build_dir.joinpath( + "flutter-packages-temp" + ) + self.out_dir = ( + Path(self.options.output_dir).resolve() + if self.options.output_dir + else self.python_app_path.joinpath(self.rel_out_dir) + ) + self.pubspec_path = str(self.flutter_dir.joinpath("pubspec.yaml")) + self.get_pyproject = load_pyproject_toml(self.python_app_path) + + def validate_target_platform(self): + assert self.options + assert self.target_platform + if ( + self.current_platform + not in self.platforms[self.target_platform]["can_be_run_on"] + or self.options.show_platform_matrix + ): + can_build_message = ( + "can't" + if self.current_platform + not in self.platforms[self.target_platform]["can_be_run_on"] + else "can" + ) + # replace "Darwin" with "macOS" for user-friendliness + self.current_platform = ( + "macOS" if self.current_platform == "Darwin" else self.current_platform + ) + # highlight the current platform in the build matrix table + self.platform_matrix_table.rows[ + list(self.platforms.keys()).index(self.target_platform) + ].style = "bold red1" + console.log(self.platform_matrix_table) + + message = f"You {can_build_message} build " + f"[cyan]{self.target_platform}[/] on " + f"[magenta]{self.current_platform}[/]." + self.cleanup(1, message) + + def validate_entry_point(self): + assert self.options + assert self.python_app_path + assert self.get_pyproject + + self.package_app_path = Path(self.python_app_path) + if self.get_pyproject("tool.flet.app.path"): + self.package_app_path = self.python_app_path.joinpath( + cast(str, self.get_pyproject("tool.flet.app.path")) + ) + + self.python_module_name = Path( + self.options.module_name + or cast(str, self.get_pyproject("tool.flet.app.module")) + or "main" + ).stem + self.python_module_filename = f"{self.python_module_name}.py" + if not self.package_app_path.joinpath(self.python_module_filename).exists(): + self.cleanup( + 1, + f"{self.python_module_filename} not found in the root of Flet " + "app directory. Use --module-name option to specify an entry point " + "for your Flet app.", + ) + + def setup_template_data(self): + assert self.options + assert self.python_app_path + assert self.get_pyproject + + base_url = ( + ( + self.options.base_url + or cast(str, self.get_pyproject("tool.flet.web.base_url")) + or "/" + ) + .strip("/") + .strip() + ) + project_name_orig = ( + self.options.project_name + or self.get_pyproject("project.name") + or self.get_pyproject("tool.poetry.name") + or self.python_app_path.name + ) + project_name_slug = slugify(cast(str, project_name_orig)) + project_name = project_name_slug.replace("-", "_") + product_name = ( + self.options.product_name + or self.get_pyproject("tool.flet.product") + or project_name_orig + ) + + split_per_abi = ( + self.options.split_per_abi + if self.options.split_per_abi is not None + else ( + self.get_pyproject("tool.flet.android.split_per_abi") + if self.get_pyproject("tool.flet.android.split_per_abi") is not None + else False + ) + ) + + info_plist = {} + macos_entitlements = { + "com.apple.security.app-sandbox": False, + "com.apple.security.cs.allow-jit": True, + "com.apple.security.network.client": True, + "com.apple.security.network.server": True, + "com.apple.security.files.user-selected.read-write": True, + } + android_permissions = {"android.permission.INTERNET": True} + android_features = { + "android.software.leanback": False, + "android.hardware.touchscreen": False, + } + android_meta_data = {} + + # merge values from "--permissions" arg: + for p in ( + self.options.permissions + or self.get_pyproject("tool.flet.permissions") + or [] + ): + if p in self.cross_platform_permissions: + info_plist.update(self.cross_platform_permissions[p]["info_plist"]) + macos_entitlements.update( + self.cross_platform_permissions[p]["macos_entitlements"] + ) + android_permissions.update( + self.cross_platform_permissions[p]["android_permissions"] + ) + android_features.update( + self.cross_platform_permissions[p]["android_features"] + ) + + info_plist = merge_dict( + info_plist, + ( + self.get_pyproject("tool.flet.macos.info") + if self.package_platform == "Darwin" + else self.get_pyproject("tool.flet.ios.info") + ) + or {}, + ) + + # parse --info-plist + for p in self.options.info_plist: + i = p.find("=") + if i > -1: + k = p[:i] + v = p[i + 1 :] + info_plist[k] = True if v == "True" else False if v == "False" else v + else: + self.cleanup(1, f"Invalid Info.plist option: {p}") + + macos_entitlements = merge_dict( + macos_entitlements, + self.get_pyproject("tool.flet.macos.entitlement") or {}, + ) + + # parse --macos-entitlements + for p in self.options.macos_entitlements: + i = p.find("=") + if i > -1: + macos_entitlements[p[:i]] = p[i + 1 :] == "True" + else: + self.cleanup(1, f"Invalid macOS entitlement option: {p}") + + android_permissions = merge_dict( + android_permissions, + self.get_pyproject("tool.flet.android.permission") or {}, + ) + + # parse --android-permissions + for p in self.options.android_permissions: + i = p.find("=") + if i > -1: + android_permissions[p[:i]] = p[i + 1 :] == "True" + else: + self.cleanup(1, f"Invalid Android permission option: {p}") + + android_features = merge_dict( + android_features, + self.get_pyproject("tool.flet.android.feature") or {}, + ) + + # parse --android-features + for p in self.options.android_features: + i = p.find("=") + if i > -1: + android_features[p[:i]] = p[i + 1 :] == "True" + else: + self.cleanup(1, f"Invalid Android feature option: {p}") + + android_meta_data = merge_dict( + android_meta_data, + self.get_pyproject("tool.flet.android.meta_data") or {}, + ) + + # parse --android-meta-data + for p in self.options.android_meta_data: + i = p.find("=") + if i > -1: + android_meta_data[p[:i]] = p[i + 1 :] + else: + self.cleanup(1, f"Invalid Android meta-data option: {p}") + + deep_linking_scheme = ( + self.get_pyproject("tool.flet.ios.deep_linking.scheme") + if self.package_platform == "iOS" + else ( + self.get_pyproject("tool.flet.android.deep_linking.scheme") + if self.package_platform == "Android" + else self.get_pyproject("tool.flet.deep_linking.scheme") + ) + ) + + deep_linking_host = ( + self.get_pyproject("tool.flet.ios.deep_linking.host") + if self.package_platform == "iOS" + else ( + self.get_pyproject("tool.flet.android.deep_linking.host") + if self.package_platform == "Android" + else self.get_pyproject("tool.flet.deep_linking.host") + ) + ) + + if self.options.deep_linking_scheme and self.options.deep_linking_host: + deep_linking_scheme = self.options.deep_linking_scheme + deep_linking_host = self.options.deep_linking_host + + target_arch = ( + self.options.target_arch + or self.get_pyproject(f"tool.flet.{self.config_platform}.target_arch") + or self.get_pyproject("tool.flet.target_arch") + ) + + ios_export_method = ( + self.options.ios_export_method + or self.get_pyproject("tool.flet.ios.export_method") + or "debugging" + ) + + ios_export_method_opts = ( + self.get_pyproject("tool.flet.ios.export_methods").get(ios_export_method) + if self.get_pyproject("tool.flet.ios.export_methods") + else {} + ) or {} + + ios_provisioning_profile = ( + self.options.ios_provisioning_profile + or self.get_pyproject("tool.flet.ios.provisioning_profile") + or ios_export_method_opts.get("provisioning_profile") + ) + + ios_signing_certificate = ( + self.options.ios_signing_certificate + or self.get_pyproject("tool.flet.ios.signing_certificate") + or ios_export_method_opts.get("signing_certificate") + ) + + ios_export_options = ( + self.get_pyproject("tool.flet.ios.export_options") + or ios_export_method_opts.get("export_options") + or {} + ) + + ios_team_id = ( + self.options.ios_team_id + or self.get_pyproject("tool.flet.ios.team_id") + or ios_export_method_opts.get("team_id") + ) + + if ( + self.target_platform in ["ipa"] + and not ios_provisioning_profile + and not self.debug_platform + ): + console.print( + Panel( + "This build will generate an .xcarchive (Xcode Archive). " + "To produce an .ipa (iOS App Package), please specify " + "a Provisioning Profile.", + style=warning_style, + ) + ) + + assert self.flutter_dir + self.template_data = { + "out_dir": self.flutter_dir.name, + "sep": os.sep, + "python_module_name": self.python_module_name, + "route_url_strategy": ( + self.options.route_url_strategy + or self.get_pyproject("tool.flet.web.route_url_strategy") + or "path" + ), + "web_renderer": ( + self.options.web_renderer + or self.get_pyproject("tool.flet.web.renderer") + or "auto" + ), + "pwa_background_color": ( + self.options.pwa_background_color + or self.get_pyproject("tool.flet.web.pwa_background_color") + ), + "pwa_theme_color": ( + self.options.pwa_theme_color + or self.get_pyproject("tool.flet.web.pwa_theme_color") + ), + "no_wasm": ( + self.options.no_wasm + or self.get_pyproject("tool.flet.web.wasm") == False # noqa: E712 + ), + "no_cdn": ( + self.options.no_cdn or self.get_pyproject("tool.flet.web.cdn") == False # noqa: E712 + ), + "base_url": f"/{base_url}/" if base_url else "/", + "split_per_abi": split_per_abi, + "project_name": project_name, + "project_name_slug": project_name_slug, + "product_name": product_name, + "description": ( + self.options.description + or self.get_pyproject("project.description") + or self.get_pyproject("tool.poetry.description") + ), + "org_name": self.options.org_name + or self.get_pyproject(f"tool.flet.{self.config_platform}.org") + or self.get_pyproject("tool.flet.org"), + "bundle_id": self.options.bundle_id + or self.get_pyproject(f"tool.flet.{self.config_platform}.bundle_id") + or self.get_pyproject("tool.flet.bundle_id"), + "company_name": ( + self.options.company_name or self.get_pyproject("tool.flet.company") + ), + "copyright": self.options.copyright + or self.get_pyproject("tool.flet.copyright"), + "ios_export_method": ios_export_method, + "ios_provisioning_profile": ios_provisioning_profile, + "ios_signing_certificate": ios_signing_certificate, + "ios_export_options": ios_export_options, + "ios_team_id": ios_team_id, + "options": { + "package_platform": self.package_platform, + "config_platform": self.config_platform, + "target_arch": ( + target_arch + if isinstance(target_arch, list) + else [target_arch] + if isinstance(target_arch, str) + else [] + ), + "info_plist": info_plist, + "macos_entitlements": macos_entitlements, + "android_permissions": android_permissions, + "android_features": android_features, + "android_meta_data": android_meta_data, + "deep_linking": { + "scheme": deep_linking_scheme, + "host": deep_linking_host, + }, + "android_signing": self.options.android_signing_key_store is not None, + }, + "flutter": {"dependencies": list(self.flutter_dependencies.keys())}, + "pyproject": self.get_pyproject(), + } + + def create_flutter_project(self, second_pass=False): + assert self.options + assert self.get_pyproject + assert self.flutter_dir + assert self.template_data + assert self.build_dir + assert self.pubspec_path + + hash = HashStamp( + self.build_dir / ".hash" / f"template-{'2' if second_pass else '1'}" + ) + + template_url = ( + self.options.template + or self.get_pyproject("tool.flet.template.url") + or DEFAULT_TEMPLATE_URL + ) + hash.update(template_url) + + template_ref = self.options.template_ref or self.get_pyproject( + "tool.flet.template.ref" + ) + if not template_ref: + template_ref = ( + version.Version(flet.version.version).base_version + if flet.version.version + else update_version() + ) + hash.update(template_ref) + + template_dir = self.options.template_dir or self.get_pyproject( + "tool.flet.template.dir" + ) + hash.update(template_dir) + hash.update(self.template_data) + + hash_changed = hash.has_changed() + + if hash_changed: + # if options.clear_cache is set, delete any existing Flutter bootstrap + # project directory + if ( + self.options.clear_cache + and self.flutter_dir.exists() + and not second_pass + ): + if self.verbose > 1: + console.log(f"Deleting {self.flutter_dir}", style=verbose2_style) + shutil.rmtree(self.flutter_dir, ignore_errors=True) + + # create a new Flutter bootstrap project directory, if non-existent + if not second_pass: + self.flutter_dir.mkdir(parents=True, exist_ok=True) + self.update_status( + "[bold blue]Creating Flutter bootstrap project from " + f'{template_url} with ref "{template_ref}"...' + ) + + try: + from cookiecutter.main import cookiecutter + + cookiecutter( + template=template_url, + checkout=template_ref, + directory=template_dir, + output_dir=str(self.flutter_dir.parent), + no_input=True, + overwrite_if_exists=True, + extra_context={ + k: v for k, v in self.template_data.items() if v is not None + }, + ) + except Exception as e: + shutil.rmtree(self.flutter_dir) + self.cleanup(1, f"{e}") + + pyproject_pubspec = self.get_pyproject("tool.flet.flutter.pubspec") + + if pyproject_pubspec: + pubspec = self.load_yaml(self.pubspec_path) + pubspec = merge_dict(pubspec, pyproject_pubspec) + self.save_yaml(self.pubspec_path, pubspec) + + # make backup of pubspec.yaml + shutil.copyfile(self.pubspec_path, f"{self.pubspec_path}.orig") + + if not second_pass: + console.log( + f"Created Flutter bootstrap project from {template_url} " + f'with ref "{template_ref}" {self.emojis["checkmark"]}' + ) + + hash.commit() + + return hash_changed + + def register_flutter_extensions(self): + assert self.flutter_packages_dir + assert self.flutter_packages_temp_dir + assert isinstance(self.flutter_dependencies, dict) + assert self.template_data + assert self.build_dir + + if self.flutter_packages_temp_dir.exists(): + # copy packages from temp to permanent location + if self.flutter_packages_dir.exists(): + shutil.rmtree(self.flutter_packages_dir, ignore_errors=True) + shutil.move(self.flutter_packages_temp_dir, self.flutter_packages_dir) + + if self.flutter_packages_dir.exists(): + self.update_status("[bold blue]Registering Flutter user extensions...") + + for fp in os.listdir(self.flutter_packages_dir): + if (self.flutter_packages_dir / fp / "pubspec.yaml").exists(): + ext_dir = str(self.flutter_packages_dir / fp) + if self.verbose > 0: + console.log(f"Found Flutter extension at {ext_dir}") + self.flutter_dependencies[fp] = {"path": ext_dir} + + self.template_data["flutter"]["dependencies"] = list( + self.flutter_dependencies.keys() + ) + + console.log( + f"Registered Flutter user extensions {self.emojis['checkmark']}" + ) + + def update_flutter_dependencies(self): + assert self.pubspec_path + assert self.template_data + assert self.get_pyproject + assert self.build_dir + assert isinstance(self.flutter_dependencies, dict) + + pubspec = self.load_yaml(self.pubspec_path) + + # merge dependencies to a dest pubspec.yaml + for k, v in self.flutter_dependencies.items(): + pubspec["dependencies"][k] = v + + # make sure project_name is not named as any of the dependencies + for dep in pubspec["dependencies"]: + if dep == self.template_data["project_name"]: + self.cleanup( + 1, + f"Project name cannot have the same name as one of its " + f"dependencies: {dep}. Use --project option to specify " + "a different project name.", + ) + + self.save_yaml(self.pubspec_path, pubspec) + + def customize_icons(self): + assert self.package_app_path + assert self.flutter_dir + assert self.options + assert self.get_pyproject + assert self.pubspec_path + assert self.build_dir + + hash = HashStamp(self.build_dir / ".hash" / "icons") + + pubspec_origin_path = f"{self.pubspec_path}.orig" + pubspec = self.load_yaml(pubspec_origin_path) + + copy_ops = [] + self.assets_path = self.package_app_path.joinpath("assets") + if self.assets_path.exists(): + images_dir = "images" + images_path = self.flutter_dir.joinpath(images_dir) + images_path.mkdir(exist_ok=True) + + # copy icons + default_icon = self.find_platform_image( + self.assets_path, images_path, "icon", copy_ops, hash + ) + ios_icon = self.find_platform_image( + self.assets_path, images_path, "icon_ios", copy_ops, hash + ) + android_icon = self.find_platform_image( + self.assets_path, images_path, "icon_android", copy_ops, hash + ) + web_icon = self.find_platform_image( + self.assets_path, images_path, "icon_web", copy_ops, hash + ) + windows_icon = self.find_platform_image( + self.assets_path, images_path, "icon_windows", copy_ops, hash + ) + macos_icon = self.find_platform_image( + self.assets_path, images_path, "icon_macos", copy_ops, hash + ) + + self.fallback_image( + pubspec, "flutter_launcher_icons.image_path", [default_icon], images_dir + ) + self.fallback_image( + pubspec, + "flutter_launcher_icons.image_path_ios", + [ios_icon, default_icon], + images_dir, + ) + self.fallback_image( + pubspec, + "flutter_launcher_icons.image_path_android", + [android_icon, default_icon], + images_dir, + ) + self.fallback_image( + pubspec, + "flutter_launcher_icons.adaptive_icon_foreground", + [android_icon, default_icon], + images_dir, + ) + self.fallback_image( + pubspec, + "flutter_launcher_icons.web.image_path", + [web_icon, default_icon], + images_dir, + ) + self.fallback_image( + pubspec, + "flutter_launcher_icons.windows.image_path", + [windows_icon, default_icon], + images_dir, + ) + self.fallback_image( + pubspec, + "flutter_launcher_icons.macos.image_path", + [macos_icon, default_icon], + images_dir, + ) + + adaptive_icon_background = ( + self.options.android_adaptive_icon_background + or self.get_pyproject("tool.flet.android.adaptive_icon_background") + ) + if adaptive_icon_background: + pubspec["flutter_launcher_icons"]["adaptive_icon_background"] = ( + adaptive_icon_background + ) + + # check if pubspec changed + hash.update(Path(pubspec_origin_path).stat().st_mtime) + hash.update(pubspec["flutter_launcher_icons"]) + + # save pubspec.yaml + if hash.has_changed(): + if copy_ops: + self.update_status("[bold blue]Customizing app icons...") + for op in copy_ops: + if self.verbose > 0: + console.log( + f"Copying image {op[0]} to {op[1]}", style=verbose1_style + ) + shutil.copy(op[0], op[1]) + console.log(f"Customized app icons {self.emojis['checkmark']}") + + updated_pubspec = self.load_yaml(self.pubspec_path) + updated_pubspec["flutter_launcher_icons"] = pubspec[ + "flutter_launcher_icons" + ] + self.save_yaml(self.pubspec_path, updated_pubspec) + + self.update_status("[bold blue]Generating app icons...") + + # icons + icons_result = self.run( + [ + self.dart_exe, + "run", + "--suppress-analytics", + "flutter_launcher_icons", + ], + cwd=str(self.flutter_dir), + capture_output=self.verbose < 1, + ) + if icons_result.returncode != 0: + if icons_result.stdout: + console.log(icons_result.stdout, style=verbose1_style) + if icons_result.stderr: + console.log(icons_result.stderr, style=error_style) + self.cleanup(icons_result.returncode) + console.log(f"Generated app icons {self.emojis['checkmark']}") + + hash.commit() + + def customize_splash_images(self): + assert self.package_app_path + assert self.flutter_dir + assert self.options + assert self.get_pyproject + assert self.pubspec_path + assert self.build_dir + assert self.target_platform + + if self.target_platform not in ["web", "ipa", "apk", "aab"]: + return + + hash = HashStamp(self.build_dir / ".hash" / "splashes") + + pubspec_origin_path = f"{self.pubspec_path}.orig" + + pubspec = self.load_yaml(pubspec_origin_path) + + copy_ops = [] + self.assets_path = self.package_app_path.joinpath("assets") + if self.assets_path.exists(): + images_dir = "images" + images_path = self.flutter_dir.joinpath(images_dir) + images_path.mkdir(exist_ok=True) + + # copy icons + default_icon = self.find_platform_image( + self.assets_path, images_path, "icon", copy_ops, hash + ) + + # copy splash images + default_splash = self.find_platform_image( + self.assets_path, images_path, "splash", copy_ops, hash + ) + default_dark_splash = self.find_platform_image( + self.assets_path, images_path, "splash_dark", copy_ops, hash + ) + ios_splash = self.find_platform_image( + self.assets_path, images_path, "splash_ios", copy_ops, hash + ) + ios_dark_splash = self.find_platform_image( + self.assets_path, images_path, "splash_dark_ios", copy_ops, hash + ) + android_splash = self.find_platform_image( + self.assets_path, images_path, "splash_android", copy_ops, hash + ) + android_dark_splash = self.find_platform_image( + self.assets_path, images_path, "splash_dark_android", copy_ops, hash + ) + web_splash = self.find_platform_image( + self.assets_path, images_path, "splash_web", copy_ops, hash + ) + web_dark_splash = self.find_platform_image( + self.assets_path, images_path, "splash_dark_web", copy_ops, hash + ) + self.fallback_image( + pubspec, + "flutter_native_splash.image", + [default_splash, default_icon], + images_dir, + ) + self.fallback_image( + pubspec, + "flutter_native_splash.image_dark", + [default_dark_splash, default_splash, default_icon], + images_dir, + ) + self.fallback_image( + pubspec, + "flutter_native_splash.image_ios", + [ios_splash, default_splash, default_icon], + images_dir, + ) + self.fallback_image( + pubspec, + "flutter_native_splash.image_dark_ios", + [ + ios_dark_splash, + default_dark_splash, + ios_splash, + default_splash, + default_icon, + ], + images_dir, + ) + self.fallback_image( + pubspec, + "flutter_native_splash.image_android", + [android_splash, default_splash, default_icon], + images_dir, + ) + self.fallback_image( + pubspec, + "flutter_native_splash.android_12.image", + [android_splash, default_splash, default_icon], + images_dir, + ) + self.fallback_image( + pubspec, + "flutter_native_splash.image_dark_android", + [ + android_dark_splash, + default_dark_splash, + android_splash, + default_splash, + default_icon, + ], + images_dir, + ) + self.fallback_image( + pubspec, + "flutter_native_splash.android_12.image_dark", + [ + android_dark_splash, + default_dark_splash, + android_splash, + default_splash, + default_icon, + ], + images_dir, + ) + self.fallback_image( + pubspec, + "flutter_native_splash.image_web", + [web_splash, default_splash, default_icon], + images_dir, + ) + self.fallback_image( + pubspec, + "flutter_native_splash.image_dark_web", + [ + web_dark_splash, + default_dark_splash, + web_splash, + default_splash, + default_icon, + ], + images_dir, + ) + + # splash colors + splash_color = ( + self.options.splash_color + or self.get_pyproject(f"tool.flet.{self.config_platform}.splash.color") + or self.get_pyproject("tool.flet.splash.color") + ) + if splash_color: + pubspec["flutter_native_splash"]["color"] = splash_color + pubspec["flutter_native_splash"]["android_12"]["color"] = splash_color + + splash_dark_color = ( + self.options.splash_dark_color + or self.get_pyproject(f"tool.flet.{self.config_platform}.splash.dark_color") + or self.get_pyproject("tool.flet.splash.dark_color") + ) + if splash_dark_color: + pubspec["flutter_native_splash"]["color_dark"] = splash_dark_color + pubspec["flutter_native_splash"]["android_12"]["color_dark"] = ( + splash_dark_color + ) + + splash_icon_bgcolor = self.get_pyproject( + f"tool.flet.{self.config_platform}.splash.icon_bgcolor" + ) or self.get_pyproject("tool.flet.splash.icon_bgcolor") + + if splash_icon_bgcolor: + pubspec["flutter_native_splash"]["android_12"]["icon_background_color"] = ( + splash_icon_bgcolor + ) + + splash_icon_dark_bgcolor = self.get_pyproject( + f"tool.flet.{self.config_platform}.splash.icon_dark_bgcolor" + ) or self.get_pyproject("tool.flet.splash.icon_dark_bgcolor") + + if splash_icon_dark_bgcolor: + pubspec["flutter_native_splash"]["android_12"][ + "icon_background_color_dark" + ] = splash_icon_dark_bgcolor + + # enable/disable splashes + pubspec["flutter_native_splash"]["web"] = ( + not self.options.no_web_splash + if self.options.no_web_splash is not None + else ( + self.get_pyproject("tool.flet.splash.web") + if self.get_pyproject("tool.flet.splash.web") is not None + else True + ) + ) + pubspec["flutter_native_splash"]["ios"] = ( + not self.options.no_ios_splash + if self.options.no_ios_splash is not None + else ( + self.get_pyproject("tool.flet.splash.ios") + if self.get_pyproject("tool.flet.splash.ios") is not None + else True + ) + ) + pubspec["flutter_native_splash"]["android"] = ( + not self.options.no_android_splash + if self.options.no_android_splash is not None + else ( + self.get_pyproject("tool.flet.splash.android") + if self.get_pyproject("tool.flet.splash.android") is not None + else True + ) + ) + + # check if pubspec changed + hash.update(Path(pubspec_origin_path).stat().st_mtime) + hash.update(pubspec["flutter_native_splash"]) + + # save pubspec.yaml + if hash.has_changed(): + if copy_ops: + self.update_status("[bold blue]Customizing app splash images...") + for op in copy_ops: + if self.verbose > 0: + console.log( + f"Copying image {op[0]} to {op[1]}", style=verbose1_style + ) + shutil.copy(op[0], op[1]) + console.log(f"Customized app splash images {self.emojis['checkmark']}") + + updated_pubspec = self.load_yaml(self.pubspec_path) + updated_pubspec["flutter_native_splash"] = pubspec["flutter_native_splash"] + self.save_yaml(self.pubspec_path, updated_pubspec) + + # splash screens + self.update_status("[bold blue]Generating splash screens...") + splash_result = self.run( + [ + self.dart_exe, + "run", + "--suppress-analytics", + "flutter_native_splash:create", + ], + cwd=str(self.flutter_dir), + capture_output=self.verbose < 1, + ) + if splash_result.returncode != 0: + if splash_result.stdout: + console.log(splash_result.stdout, style=verbose1_style) + if splash_result.stderr: + console.log(splash_result.stderr, style=error_style) + self.cleanup(splash_result.returncode) + console.log(f"Generated splash screens {self.emojis['checkmark']}") + + hash.commit() + + def fallback_image(self, pubspec, yaml_path: str, images: list, images_dir: str): + d = pubspec + pp = yaml_path.split(".") + for p in pp[:-1]: + d = d[p] + for image in images: + if image: + d[pp[-1]] = f"{images_dir}/{image}" + return + + def package_python_app(self): + assert self.options + assert self.get_pyproject + assert self.python_app_path + assert self.package_app_path + assert self.build_dir + assert self.flutter_dir + assert self.flutter_packages_dir + assert self.flutter_packages_temp_dir + assert self.template_data + + hash = HashStamp(self.build_dir / ".hash" / "package") + + self.update_status("[bold blue]Packaging Python app...") + package_args = [ + self.dart_exe, + "run", + "--suppress-analytics", + "serious_python:main", + "package", + str(self.package_app_path), + "--platform", + self.package_platform, + ] + + if self.template_data["options"]["target_arch"]: + package_args.extend( + ["--arch"] + self.template_data["options"]["target_arch"] + ) + + package_env = {} + + # requirements + requirements_txt = self.python_app_path.joinpath("requirements.txt") + + toml_dependencies = ( + get_poetry_dependencies(self.get_pyproject("tool.poetry.dependencies")) + or get_project_dependencies(self.get_pyproject("project.dependencies")) + or [] + ) + + platform_dependencies = get_project_dependencies( + self.get_pyproject(f"tool.flet.{self.config_platform}.dependencies") + ) + if platform_dependencies: + toml_dependencies.extend(platform_dependencies) + + dev_packages_configured = False + if len(toml_dependencies) > 0: + dev_packages = ( + self.get_pyproject(f"tool.flet.{self.config_platform}.dev_packages") + or self.get_pyproject("tool.flet.dev_packages") + or [] + ) + if len(dev_packages) > 0: + for i in range(0, len(toml_dependencies)): + package_name = Requirement(toml_dependencies[i]).name + if package_name in dev_packages: + package_location = dev_packages[package_name] + dev_path = Path(package_location) + if not dev_path.is_absolute(): + dev_path = (self.python_app_path / dev_path).resolve() + if dev_path.exists(): + toml_dependencies[i] = f"{package_name} @ file://{dev_path}" + else: + toml_dependencies[i] = ( + f"{package_name} @ {package_location}" + ) + dev_packages_configured = True + if dev_packages_configured: + toml_dependencies.append("--no-cache-dir") + + for toml_dep in toml_dependencies: + package_args.extend(["-r", toml_dep]) + + elif requirements_txt.exists(): + if self.verbose > 1: + with open(requirements_txt, encoding="utf-8") as f: + reqs_txt_contents = f.read() + console.log( + f"Contents of requirements.txt: {reqs_txt_contents}", + style=verbose2_style, + ) + hash.update(reqs_txt_contents) + package_args.extend(["-r", "-r", "-r", str(requirements_txt)]) + else: + flet_version = ( + flet.version.version if flet.version.version else update_version() + ) + package_args.extend(["-r", f"flet=={flet_version}"]) + + # site-packages variable + if self.package_platform != "Pyodide": + package_env["SERIOUS_PYTHON_SITE_PACKAGES"] = str( + self.build_dir / "site-packages" + ) + + # flutter-packages variable + if self.flutter_packages_temp_dir.exists(): + shutil.rmtree(self.flutter_packages_temp_dir) + + package_env["SERIOUS_PYTHON_FLUTTER_PACKAGES"] = str( + self.flutter_packages_temp_dir + ) + + # exclude + exclude_list = ["build"] + + app_exclude = ( + self.options.exclude + or self.get_pyproject(f"tool.flet.{self.config_platform}.app.exclude") + or self.get_pyproject("tool.flet.app.exclude") + ) + if app_exclude: + exclude_list.extend(app_exclude) + + if self.target_platform == "web": + exclude_list.append("assets") + package_args.extend(["--exclude", ",".join(exclude_list)]) + + # source-packages + source_packages = ( + self.options.source_packages + or self.get_pyproject(f"tool.flet.{self.config_platform}.source_packages") + or self.get_pyproject("tool.flet.source_packages") + ) + if source_packages: + package_env["SERIOUS_PYTHON_ALLOW_SOURCE_DISTRIBUTIONS"] = ",".join( + source_packages + ) + + if self.get_bool_setting(self.options.compile_app, "compile.app", False): + package_args.append("--compile-app") + + if self.get_bool_setting( + self.options.compile_packages, "compile.packages", False + ): + package_args.append("--compile-packages") + + cleanup_app = self.get_bool_setting( + self.options.cleanup_app, "cleanup.app", False + ) + cleanup_packages = self.get_bool_setting( + self.options.cleanup_packages, "cleanup.packages", True + ) + + # TODO: should be deprecated + if self.get_bool_setting(None, "compile.cleanup", False): + cleanup_app = cleanup_packages = True + + if cleanup_app_files := ( + self.options.cleanup_app_files + or self.get_pyproject(f"tool.flet.{self.config_platform}.cleanup.app_files") + or self.get_pyproject("tool.flet.cleanup.app_files") + ): + package_args.extend(["--cleanup-app-files", ",".join(cleanup_app_files)]) + cleanup_app = True + + if cleanup_package_files := ( + self.options.cleanup_package_files + or self.get_pyproject( + f"tool.flet.{self.config_platform}.cleanup.package_files" + ) + or self.get_pyproject("tool.flet.cleanup.package_files") + ): + package_args.extend( + ["--cleanup-package-files", ",".join(cleanup_package_files)] + ) + cleanup_packages = True + + if cleanup_app: + package_args.append("--cleanup-app") + + if cleanup_packages: + package_args.append("--cleanup-packages") + + if self.verbose > 1: + package_args.append("--verbose") + + # check if site-packages installation could be skipped + for arg in package_args: + hash.update(arg) + + if not dev_packages_configured: + if not hash.has_changed(): + package_args.append("--skip-site-packages") + else: + if self.flutter_packages_dir.exists(): + shutil.rmtree(self.flutter_packages_dir, ignore_errors=True) + + package_result = self.run( + package_args, + cwd=str(self.flutter_dir), + env=package_env, + capture_output=self.verbose < 1, + ) + + if package_result.returncode != 0: + if package_result.stdout: + console.log(package_result.stdout, style=verbose1_style) + if package_result.stderr: + console.log(package_result.stderr, style=error_style) + self.cleanup(package_result.returncode) + + hash.commit() + + # make sure app/app.zip exists + app_zip_path = self.flutter_dir.joinpath("app", "app.zip") + if not os.path.exists(app_zip_path): + self.cleanup(1, "Flet app package app/app.zip was not created.") + + console.log(f"Packaged Python app {self.emojis['checkmark']}") + + def get_bool_setting(self, cli_option, pyproj_setting, default_value): + assert self.get_pyproject + return ( + cli_option + if cli_option is not None + else ( + self.get_pyproject(f"tool.flet.{self.config_platform}.{pyproj_setting}") + if self.get_pyproject( + f"tool.flet.{self.config_platform}.{pyproj_setting}" + ) + is not None + else ( + self.get_pyproject(f"tool.flet.{pyproj_setting}") + if self.get_pyproject(f"tool.flet.{pyproj_setting}") is not None + else default_value + ) + ) + ) + + def add_flutter_command_args(self, args: list[str]): + pass + + def run_flutter(self): + self._run_flutter_command() + + def _run_flutter_command(self): + assert self.options + assert self.build_dir + assert self.get_pyproject + assert self.template_data + assert self.target_platform + + # flutter build + build_args = [self.flutter_exe] + self.add_flutter_command_args(build_args) + build_args.extend( + [ + "--no-version-check", + "--suppress-analytics", + ] + ) + + build_env = {} + + # site-packages variable + if self.package_platform != "Pyodide": + build_env["SERIOUS_PYTHON_SITE_PACKAGES"] = str( + self.build_dir / "site-packages" + ) + + if self.package_platform == "Pyodide" and not self.template_data["no_wasm"]: + build_args.append("--wasm") + + android_signing_key_store = ( + self.options.android_signing_key_store + or self.get_pyproject("tool.flet.android.signing.key_store") + ) + if android_signing_key_store: + build_env["FLET_ANDROID_SIGNING_KEY_STORE"] = android_signing_key_store + + key_store_password = ( + self.options.android_signing_key_store_password + or os.getenv("FLET_ANDROID_SIGNING_KEY_STORE_PASSWORD") + ) + key_password = self.options.android_signing_key_password or os.getenv( + "FLET_ANDROID_SIGNING_KEY_PASSWORD" + ) + if key_store_password or key_password: + build_env["FLET_ANDROID_SIGNING_KEY_STORE_PASSWORD"] = ( + key_store_password if key_store_password else key_password + ) + build_env["FLET_ANDROID_SIGNING_KEY_PASSWORD"] = ( + key_password if key_password else key_store_password + ) + + android_signing_key_alias = ( + self.options.android_signing_key_alias + or self.get_pyproject("tool.flet.android.signing.key_alias") + ) + if android_signing_key_alias: + build_env["FLET_ANDROID_SIGNING_KEY_ALIAS"] = android_signing_key_alias + + if self.options.flutter_build_args: + for flutter_build_arg_arr in self.options.flutter_build_args: + build_args.extend(flutter_build_arg_arr) + + if self.verbose > 1: + build_args.append("--verbose") + + build_result = self.run( + build_args, + cwd=str(self.flutter_dir), + env=build_env, + capture_output=self.verbose < 1, + ) + + if ( + build_result.returncode != 0 + or "Encountered error while creating the IPA" in str(build_result.stderr) + ): + if build_result.stdout: + console.log(build_result.stdout, style=verbose1_style) + if build_result.stderr: + console.log(build_result.stderr, style=error_style) + self.cleanup(build_result.returncode if build_result.returncode else 1) + + def copy_build_output(self): + assert self.template_data + assert self.options + assert self.flutter_dir + assert self.out_dir + assert self.assets_path + assert self.target_platform + + self.update_status( + f"[bold blue]Copying build to [cyan]{self.rel_out_dir}[/cyan] directory...", + ) + arch = platform.machine().lower() + if arch in {"x86_64", "amd64"}: + arch = "x64" + elif arch in {"arm64", "aarch64"}: + arch = "arm64" + + def make_ignore_fn(out_dir, out_glob): + def ignore(path, names): + if path == out_dir and out_glob != "*": + return [f for f in os.listdir(path) if f != out_glob] + return [] + + return ignore + + for build_output in self.platforms[self.target_platform]["outputs"]: + build_output_dir = ( + str(self.flutter_dir.joinpath(build_output)) + .replace("{arch}", arch) + .replace("{project_name}", self.template_data["project_name"]) + .replace("{product_name}", self.template_data["product_name"]) + ) + + if self.verbose > 0: + console.log( + "Copying build output from: " + build_output_dir, + style=verbose1_style, + ) + + build_output_glob = os.path.basename(build_output_dir) + build_output_dir = os.path.dirname(build_output_dir) + if not os.path.exists(build_output_dir): + continue + + if self.out_dir.exists(): + shutil.rmtree(str(self.out_dir)) + self.out_dir.mkdir(parents=True, exist_ok=True) + + # copy build result to out_dir + copy_tree( + build_output_dir, + str(self.out_dir), + ignore=make_ignore_fn(build_output_dir, build_output_glob), + ) + + if self.target_platform == "web" and self.assets_path.exists(): + # copy `assets` directory contents to the output directory + copy_tree(str(self.assets_path), str(self.out_dir)) + + console.log( + f"Copied build to [cyan]{self.rel_out_dir}[/cyan] " + f"directory {self.emojis['checkmark']}" + ) + + def find_platform_image( + self, + src_path: Path, + dest_path: Path, + image_name: str, + copy_ops: list, + hash: HashStamp, + ): + images = glob.glob(str(src_path.joinpath(f"{image_name}.*"))) + if len(images) > 0: + if self.verbose > 0: + console.log( + f'Found "{image_name}" image at {images[0]}', style=verbose1_style + ) + copy_ops.append((images[0], dest_path)) + hash.update(images[0]) + hash.update(Path(images[0]).stat().st_mtime) + return Path(images[0]).name + return None + + def find_flutter_batch(self, exe_filename: str): + batch_path = shutil.which(exe_filename) + if not batch_path: + return None + if is_windows() and batch_path.endswith(".file"): + return batch_path.replace(".file", ".bat") + return batch_path + + def run(self, args, cwd, env: Optional[dict] = None, capture_output=True): + if self.verbose > 0: + console.log(f"Run subprocess: {args}", style=verbose1_style) + + return processes.run( + args, + cwd, + env={**self.env, **env} if env else self.env, + capture_output=capture_output, + log=self.log_stdout, + ) + + def cleanup(self, exit_code: int, message: Optional[str] = None): + if exit_code == 0: + msg = message or f"Success! {self.emojis['success']}" + self.live.update(Panel(msg), refresh=True) + else: + msg = ( + message + if message is not None + else "Error building Flet app - see the log of failed command above." + ) + + # windows has been reported to raise encoding errors + # when running `flutter doctor` + # so skip running `flutter doctor` if no_rich_output is True + # and platform is Windows + if not ( + (self.no_rich_output and self.current_platform == "Windows") + or self.skip_flutter_doctor + ): + status = console.status( + "[bold blue]Running Flutter doctor...", + spinner="bouncingBall", + ) + self.live.update( + Group(Panel(msg, style=error_style), status), refresh=True + ) + self.run_flutter_doctor() + self.live.update(Panel(msg, style=error_style), refresh=True) + + sys.exit(exit_code) + + def run_flutter_doctor(self): + flutter_doctor = self.run( + [self.flutter_exe, "doctor", "--no-version-check", "--suppress-analytics"], + cwd=os.getcwd(), + capture_output=True, + ) + if flutter_doctor.returncode == 0 and flutter_doctor.stdout: + console.log(flutter_doctor.stdout, style=verbose1_style) + + def update_status(self, status): + if self.no_rich_output: + console.log(status) + else: + self.status.update(status) + + def log_stdout(self, message): + if self.verbose > 0: + console.log( + message, + end="", + style=verbose2_style, + markup=False, + ) + + def load_yaml(self, path): + with open(str(path), encoding="utf-8") as f: + return yaml.safe_load(f) + + def save_yaml(self, path, doc): + with open(str(path), "w", encoding="utf-8") as f: + yaml.dump(doc, f) diff --git a/sdk/python/packages/flet-cli/src/flet_cli/commands/debug.py b/sdk/python/packages/flet-cli/src/flet_cli/commands/debug.py index abd6de908..a2133a394 100644 --- a/sdk/python/packages/flet-cli/src/flet_cli/commands/debug.py +++ b/sdk/python/packages/flet-cli/src/flet_cli/commands/debug.py @@ -7,10 +7,10 @@ from rich.live import Live from rich.progress import Progress -from flet_cli.commands.flutter_base import BaseFlutterCommand, console, verbose2_style +from flet_cli.commands.build_base import BaseBuildCommand, console, verbose2_style -class Command(BaseFlutterCommand): +class Command(BaseBuildCommand): """ Run a Flet Python app in debug mode on a specified platform (desktop, web, mobile). """ diff --git a/sdk/python/packages/flet-cli/src/flet_cli/commands/flutter_base.py b/sdk/python/packages/flet-cli/src/flet_cli/commands/flutter_base.py index 7694a7011..71375bb49 100644 --- a/sdk/python/packages/flet-cli/src/flet_cli/commands/flutter_base.py +++ b/sdk/python/packages/flet-cli/src/flet_cli/commands/flutter_base.py @@ -1,35 +1,21 @@ import argparse -import glob import os import platform import re import shutil import sys -from pathlib import Path -from typing import Optional, cast +from typing import Optional -import yaml from packaging import version -from packaging.requirements import Requirement from rich.console import Console, Group from rich.panel import Panel from rich.style import Style -from rich.table import Column, Table from rich.theme import Theme -import flet.version import flet_cli.utils.processes as processes -from flet.utils import cleanup_path, copy_tree, is_windows, slugify +from flet.utils import cleanup_path, is_windows from flet.utils.platform_utils import get_bool_env_var -from flet.version import update_version from flet_cli.commands.base import BaseCommand -from flet_cli.utils.hash_stamp import HashStamp -from flet_cli.utils.merge import merge_dict -from flet_cli.utils.project_dependencies import ( - get_poetry_dependencies, - get_project_dependencies, -) -from flet_cli.utils.pyproject_toml import load_pyproject_toml PYODIDE_ROOT_URL = "https://cdn.jsdelivr.net/pyodide/v0.27.7/full" DEFAULT_TEMPLATE_URL = "gh:flet-dev/flet-build-template" @@ -51,564 +37,23 @@ class BaseFlutterCommand(BaseCommand): """ - A base Flutter CLI subcommand + A base Flutter CLI command. """ def __init__(self, parser: argparse.ArgumentParser) -> None: super().__init__(parser) self.env = {} - self.pubspec_path = None - self.rel_out_dir = None - self.assets_path = None - self.target_platform = None - self.debug_platform = None - self.package_platform = None - self.config_platform = None - self.flutter_dependencies = {} - self.package_app_path = None self.options = None - self.template_data = None - self.python_module_filename = None - self.out_dir = None - self.python_module_name = None - self.get_pyproject = None - self.python_app_path = None self.emojis = {} self.dart_exe = None - self.verbose = False - self.build_dir = None - self.flutter_dir: Optional[Path] = None - self.flutter_packages_dir = None - self.flutter_packages_temp_dir = None self.flutter_exe = None + self.verbose = False self.skip_flutter_doctor = get_bool_env_var("FLET_CLI_SKIP_FLUTTER_DOCTOR") self.no_rich_output = no_rich_output self.current_platform = platform.system() - self.platforms = { - "windows": { - "package_platform": "Windows", - "config_platform": "windows", - "flutter_build_command": "windows", - "status_text": "Windows app", - "outputs": ["build/windows/x64/runner/Release/*"], - "dist": "windows", - "can_be_run_on": ["Windows"], - }, - "macos": { - "package_platform": "Darwin", - "config_platform": "macos", - "flutter_build_command": "macos", - "status_text": "macOS bundle", - "outputs": ["build/macos/Build/Products/Release/{product_name}.app"], - "dist": "macos", - "can_be_run_on": ["Darwin"], - }, - "linux": { - "package_platform": "Linux", - "config_platform": "linux", - "flutter_build_command": "linux", - "status_text": "app for Linux", - "outputs": ["build/linux/{arch}/release/bundle/*"], - "dist": "linux", - "can_be_run_on": ["Linux"], - }, - "web": { - "package_platform": "Pyodide", - "config_platform": "web", - "flutter_build_command": "web", - "status_text": "web app", - "outputs": ["build/web/*"], - "dist": "web", - "can_be_run_on": ["Darwin", "Windows", "Linux"], - }, - "apk": { - "package_platform": "Android", - "config_platform": "android", - "flutter_build_command": "apk", - "status_text": ".apk for Android", - "outputs": ["build/app/outputs/flutter-apk/*"], - "dist": "apk", - "can_be_run_on": ["Darwin", "Windows", "Linux"], - }, - "aab": { - "package_platform": "Android", - "config_platform": "android", - "flutter_build_command": "appbundle", - "status_text": ".aab bundle for Android", - "outputs": ["build/app/outputs/bundle/release/*"], - "dist": "aab", - "can_be_run_on": ["Darwin", "Windows", "Linux"], - }, - "ipa": { - "package_platform": "iOS", - "config_platform": "ios", - "flutter_build_command": "ipa", - "status_text": ".ipa bundle for iOS", - "outputs": ["build/ios/archive/*", "build/ios/ipa/*"], - "dist": "ipa", - "can_be_run_on": ["Darwin"], - }, - } - - self.cross_platform_permissions = { - "location": { - "info_plist": { - "NSLocationWhenInUseUsageDescription": "This app uses location " - "service when in use.", - "NSLocationAlwaysAndWhenInUseUsageDescription": "This app uses " - "location service.", - }, - "macos_entitlements": { - "com.apple.security.personal-information.location": True - }, - "android_permissions": { - "android.permission.ACCESS_FINE_LOCATION": True, - "android.permission.ACCESS_COARSE_LOCATION": True, - "android.permission.ACCESS_BACKGROUND_LOCATION": True, - }, - "android_features": { - "android.hardware.location.network": False, - "android.hardware.location.gps": False, - }, - }, - "camera": { - "info_plist": { - "NSCameraUsageDescription": "This app uses the camera to capture " - "photos and videos." - }, - "macos_entitlements": {"com.apple.security.device.camera": True}, - "android_permissions": {"android.permission.CAMERA": True}, - "android_features": { - "android.hardware.camera": False, - "android.hardware.camera.any": False, - "android.hardware.camera.front": False, - "android.hardware.camera.external": False, - "android.hardware.camera.autofocus": False, - }, - }, - "microphone": { - "info_plist": { - "NSMicrophoneUsageDescription": "This app uses microphone to " - "record sounds.", - }, - "macos_entitlements": {"com.apple.security.device.audio-input": True}, - "android_permissions": { - "android.permission.RECORD_AUDIO": True, - "android.permission.WRITE_EXTERNAL_STORAGE": True, - "android.permission.READ_EXTERNAL_STORAGE": True, - }, - "android_features": {}, - }, - "photo_library": { - "info_plist": { - "NSPhotoLibraryUsageDescription": "This app saves photos and " - "videos to the photo library." - }, - "macos_entitlements": { - "com.apple.security.personal-information.photos-library": True - }, - "android_permissions": { - "android.permission.READ_MEDIA_VISUAL_USER_SELECTED": True - }, - "android_features": {}, - }, - } - - # create and display build-platform-matrix table - self.platform_matrix_table = Table( - Column("Command", style="cyan", justify="left"), - Column("Platform", style="magenta", justify="center"), - title="Build Platform Matrix", - header_style="bold", - show_lines=True, - ) - for p, info in self.platforms.items(): - self.platform_matrix_table.add_row( - "flet build " + p, - ", ".join(info["can_be_run_on"]).replace("Darwin", "macOS"), - ) def add_arguments(self, parser: argparse.ArgumentParser) -> None: - parser.add_argument( - "python_app_path", - type=str, - nargs="?", - default=".", - help="Path to a directory with a Flet Python program", - ) - parser.add_argument( - "--arch", - dest="target_arch", - nargs="+", - default=[], - help="Build for specific CPU architectures " - "(used in macOS and Android builds only). Example: `--arch arm64 x64`", - ) - parser.add_argument( - "--exclude", - dest="exclude", - nargs="+", - default=[], - help="Files and/or directories to exclude from the package", - ) - parser.add_argument( - "--clear-cache", - dest="clear_cache", - action="store_true", - default=None, - help="Remove any existing build cache before starting the build process", - ) - parser.add_argument( - "--project", - dest="project_name", - required=False, - help="Project name for the executable/bundle. " - "It is used in metadata and bundle IDs", - ) - parser.add_argument( - "--description", - dest="description", - required=False, - help="Short description of the application", - ) - parser.add_argument( - "--product", - dest="product_name", - required=False, - help="Display name of the app that is shown in window titles " - "and about app dialogs", - ) - parser.add_argument( - "--org", - dest="org_name", - required=False, - help="Organization name in reverse domain name notation, " - "e.g. `com.mycompany`, combined with project name and " - "used in bundle IDs and signing", - ) - parser.add_argument( - "--bundle-id", - dest="bundle_id", - required=False, - help="Bundle ID for the application, e.g. `com.mycompany.app-name`. " - "It is used as an iOS, Android, macOS and Linux bundle ID", - ) - parser.add_argument( - "--company", - dest="company_name", - required=False, - help="Company name to display in about app dialogs", - ) - parser.add_argument( - "--copyright", - dest="copyright", - required=False, - help="Copyright text to display in about app dialogs", - ) - parser.add_argument( - "--android-adaptive-icon-background", - dest="android_adaptive_icon_background", - required=False, - help="The color to be used to fill out the background of " - "Android adaptive icons", - ) - parser.add_argument( - "--splash-color", - dest="splash_color", - required=False, - help="Background color of app splash screen on iOS, Android and web", - ) - parser.add_argument( - "--splash-dark-color", - dest="splash_dark_color", - required=False, - help="Background color in dark mode of app splash screen on " - "iOS, Android and web", - ) - parser.add_argument( - "--no-web-splash", - dest="no_web_splash", - action="store_true", - default=None, - help="Disable splash screen on web platform", - ) - parser.add_argument( - "--no-ios-splash", - dest="no_ios_splash", - action="store_true", - default=None, - help="Disable splash screen on iOS platform", - ) - parser.add_argument( - "--no-android-splash", - dest="no_android_splash", - action="store_true", - default=None, - help="Disable splash screen on Android platform", - ) - parser.add_argument( - "--ios-team-id", - dest="ios_team_id", - type=str, - help="Apple developer team ID for signing iOS app bundle (ipa only)", - required=False, - ) - parser.add_argument( - "--ios-export-method", - dest="ios_export_method", - type=str, - required=False, - help="Export method for iOS app bundle (default: debugging)", - ) - parser.add_argument( - "--ios-provisioning-profile", - dest="ios_provisioning_profile", - type=str, - required=False, - help="Provisioning profile name or UUID that should be used to sign and " - "export iOS app bundle", - ) - parser.add_argument( - "--ios-signing-certificate", - dest="ios_signing_certificate", - type=str, - required=False, - help="Signing certificate name, SHA-1 hash, or automatic selector to use " - "for signing iOS app bundle", - ) - parser.add_argument( - "--base-url", - dest="base_url", - type=str, - help="Base URL from which the app is served (web only)", - ) - parser.add_argument( - "--web-renderer", - dest="web_renderer", - choices=["auto", "canvaskit", "skwasm"], - help="Flutter web renderer to use (web only)", - ) - parser.add_argument( - "--route-url-strategy", - dest="route_url_strategy", - choices=["path", "hash"], - help="Base URL path to serve the app from. " - "Useful if the app is hosted in a subdirectory (web only)", - ) - parser.add_argument( - "--pwa-background-color", - dest="pwa_background_color", - required=False, - help="Initial background color for your web app (web only)", - ) - parser.add_argument( - "--pwa-theme-color", - dest="pwa_theme_color", - required=False, - help="Default color for your web app's user interface (web only)", - ) - parser.add_argument( - "--no-wasm", - dest="no_wasm", - action="store_true", - default=False, - help="Disable WASM target for web build (web only)", - ) - parser.add_argument( - "--no-cdn", - dest="no_cdn", - action="store_true", - default=False, - help="Disable loading of CanvasKit, Pyodide and fonts from CDN", - ) - parser.add_argument( - "--split-per-abi", - dest="split_per_abi", - action="store_true", - default=None, - help="Split the APKs per ABIs (Android only)", - ) - parser.add_argument( - "--compile-app", - dest="compile_app", - action="store_true", - default=None, - help="Pre-compile app's `.py` files to `.pyc`", - ) - parser.add_argument( - "--compile-packages", - dest="compile_packages", - action="store_true", - default=None, - help="Pre-compile site packages' `.py` files to `.pyc`", - ) - parser.add_argument( - "--cleanup-app", - dest="cleanup_app", - action="store_true", - default=None, - help="Remove unnecessary app files upon packaging", - ) - parser.add_argument( - "--cleanup-app-files", - dest="cleanup_app_files", - action="append", - nargs="*", - help="The list of globs to delete extra app files and directories", - ) - parser.add_argument( - "--cleanup-packages", - dest="cleanup_packages", - action="store_true", - default=None, - help="Remove unnecessary package files upon packaging", - ) - parser.add_argument( - "--cleanup-package-files", - dest="cleanup_package_files", - action="append", - nargs="*", - help="The list of globs to delete extra package files and directories", - ) - parser.add_argument( - "--flutter-build-args", - dest="flutter_build_args", - action="append", - nargs="*", - help="Additional arguments for flutter build command", - ) - parser.add_argument( - "--source-packages", - dest="source_packages", - nargs="+", - default=[], - help="The list of Python packages to install from source distributions", - ) - parser.add_argument( - "--info-plist", - dest="info_plist", - nargs="+", - default=[], - help="The list of `=|True|False` pairs to add to Info.plist " - "for macOS and iOS builds (macos and ipa only)", - ) - parser.add_argument( - "--macos-entitlements", - dest="macos_entitlements", - nargs="+", - default=[], - help="The list of `=|True|False` entitlements for " - "macOS builds (macos only)", - ) - parser.add_argument( - "--android-features", - dest="android_features", - nargs="+", - default=[], - help="The list of `=True|False` features to add to " - "AndroidManifest.xml for Android builds (android only)", - ) - parser.add_argument( - "--android-permissions", - dest="android_permissions", - nargs="+", - default=[], - help="The list of `=True|False` permissions to add to " - "AndroidManifest.xml for Android builds (android only)", - ) - parser.add_argument( - "--android-meta-data", - dest="android_meta_data", - nargs="+", - default=[], - help="The list of `=` app meta-data entries to add to " - "AndroidManifest.xml for Android builds (android only)", - ) - parser.add_argument( - "--permissions", - dest="permissions", - nargs="+", - default=[], - choices=["location", "camera", "microphone", "photo_library"], - help="The list of pre-defined cross-platform permissions for iOS, Android " - "and macOS builds", - ) - parser.add_argument( - "--deep-linking-scheme", - dest="deep_linking_scheme", - help="Deep linking URL scheme to configure for iOS and Android builds, " - "i.g. `https` or `myapp`", - ) - parser.add_argument( - "--deep-linking-host", - dest="deep_linking_host", - help="Deep linking URL host for iOS and Android builds", - ) - parser.add_argument( - "--android-signing-key-store", - dest="android_signing_key_store", - help="path to an upload keystore `.jks` file for Android apps", - ) - parser.add_argument( - "--android-signing-key-store-password", - dest="android_signing_key_store_password", - help="Android signing store password", - ) - parser.add_argument( - "--android-signing-key-password", - dest="android_signing_key_password", - help="Android signing key password", - ) - parser.add_argument( - "--android-signing-key-alias", - dest="android_signing_key_alias", - default="upload", - help="Android signing key alias (default: upload)", - ) - parser.add_argument( - "--build-number", - dest="build_number", - type=int, - help="Build number - an identifier used as an internal version number", - ) - parser.add_argument( - "--build-version", - dest="build_version", - help="Build version - a `x.y.z` string used as the version number " - "shown to users", - ) - parser.add_argument( - "--module-name", - dest="module_name", - help="Python module name with an app entry point", - ) - parser.add_argument( - "--template", - dest="template", - type=str, - help="Directory containing Flutter bootstrap template, or a URL " - "to a git repository template", - ) - parser.add_argument( - "--template-dir", - dest="template_dir", - type=str, - help="Relative path to a Flutter bootstrap template in a repository", - ) - parser.add_argument( - "--template-ref", - dest="template_ref", - type=str, - help="The branch, tag or commit ID to checkout after cloning " - "the repository with Flutter bootstrap template", - ) - parser.add_argument( - "--show-platform-matrix", - action="store_true", - default=False, - help="Display the build platform matrix in a table, then exit", - ) parser.add_argument( "--no-rich-output", action="store_true", @@ -624,8 +69,6 @@ def add_arguments(self, parser: argparse.ArgumentParser) -> None: def handle(self, options: argparse.Namespace) -> None: self.options = options - if "target_platform" in self.options: - self.target_platform = self.options.target_platform def initialize_build(self): assert self.options @@ -637,21 +80,9 @@ def initialize_build(self): "directory": "" if self.no_rich_output else "📁", } - self.python_app_path = Path(self.options.python_app_path).resolve() self.skip_flutter_doctor = ( self.skip_flutter_doctor or self.options.skip_flutter_doctor ) - self.package_platform = self.platforms[self.target_platform]["package_platform"] - self.config_platform = self.platforms[self.target_platform]["config_platform"] - - if not ( - os.path.exists(self.python_app_path) or os.path.isdir(self.python_app_path) - ): - self.cleanup( - 1, - f"Path to Flet app does not exist or is not a directory: " - f"{self.python_app_path}", - ) # get `flutter` and `dart` executables from PATH self.flutter_exe = self.find_flutter_batch("flutter") @@ -672,24 +103,6 @@ def initialize_build(self): self.install_jdk() self.install_android_sdk() - self.rel_out_dir = self.options.output_dir or os.path.join( - "build", self.platforms[self.target_platform]["dist"] - ) - - self.build_dir = self.python_app_path.joinpath("build") - self.flutter_dir = self.build_dir.joinpath("flutter") - self.flutter_packages_dir = self.build_dir.joinpath("flutter-packages") - self.flutter_packages_temp_dir = self.build_dir.joinpath( - "flutter-packages-temp" - ) - self.out_dir = ( - Path(self.options.output_dir).resolve() - if self.options.output_dir - else self.python_app_path.joinpath(self.rel_out_dir) - ) - self.pubspec_path = str(self.flutter_dir.joinpath("pubspec.yaml")) - self.get_pyproject = load_pyproject_toml(self.python_app_path) - def flutter_version_valid(self): version_results = self.run( [ @@ -803,1313 +216,6 @@ def install_android_sdk(self): ).install() console.log(f"Android SDK installed {self.emojis['checkmark']}") - def validate_target_platform(self): - assert self.options - assert self.target_platform - if ( - self.current_platform - not in self.platforms[self.target_platform]["can_be_run_on"] - or self.options.show_platform_matrix - ): - can_build_message = ( - "can't" - if self.current_platform - not in self.platforms[self.target_platform]["can_be_run_on"] - else "can" - ) - # replace "Darwin" with "macOS" for user-friendliness - self.current_platform = ( - "macOS" if self.current_platform == "Darwin" else self.current_platform - ) - # highlight the current platform in the build matrix table - self.platform_matrix_table.rows[ - list(self.platforms.keys()).index(self.target_platform) - ].style = "bold red1" - console.log(self.platform_matrix_table) - - message = f"You {can_build_message} build " - f"[cyan]{self.target_platform}[/] on " - f"[magenta]{self.current_platform}[/]." - self.cleanup(1, message) - - def validate_entry_point(self): - assert self.options - assert self.python_app_path - assert self.get_pyproject - - self.package_app_path = Path(self.python_app_path) - if self.get_pyproject("tool.flet.app.path"): - self.package_app_path = self.python_app_path.joinpath( - cast(str, self.get_pyproject("tool.flet.app.path")) - ) - - self.python_module_name = Path( - self.options.module_name - or cast(str, self.get_pyproject("tool.flet.app.module")) - or "main" - ).stem - self.python_module_filename = f"{self.python_module_name}.py" - if not self.package_app_path.joinpath(self.python_module_filename).exists(): - self.cleanup( - 1, - f"{self.python_module_filename} not found in the root of Flet " - "app directory. Use --module-name option to specify an entry point " - "for your Flet app.", - ) - - def setup_template_data(self): - assert self.options - assert self.python_app_path - assert self.get_pyproject - - base_url = ( - ( - self.options.base_url - or cast(str, self.get_pyproject("tool.flet.web.base_url")) - or "/" - ) - .strip("/") - .strip() - ) - project_name_orig = ( - self.options.project_name - or self.get_pyproject("project.name") - or self.get_pyproject("tool.poetry.name") - or self.python_app_path.name - ) - project_name_slug = slugify(cast(str, project_name_orig)) - project_name = project_name_slug.replace("-", "_") - product_name = ( - self.options.product_name - or self.get_pyproject("tool.flet.product") - or project_name_orig - ) - - split_per_abi = ( - self.options.split_per_abi - if self.options.split_per_abi is not None - else ( - self.get_pyproject("tool.flet.android.split_per_abi") - if self.get_pyproject("tool.flet.android.split_per_abi") is not None - else False - ) - ) - - info_plist = {} - macos_entitlements = { - "com.apple.security.app-sandbox": False, - "com.apple.security.cs.allow-jit": True, - "com.apple.security.network.client": True, - "com.apple.security.network.server": True, - "com.apple.security.files.user-selected.read-write": True, - } - android_permissions = {"android.permission.INTERNET": True} - android_features = { - "android.software.leanback": False, - "android.hardware.touchscreen": False, - } - android_meta_data = {} - - # merge values from "--permissions" arg: - for p in ( - self.options.permissions - or self.get_pyproject("tool.flet.permissions") - or [] - ): - if p in self.cross_platform_permissions: - info_plist.update(self.cross_platform_permissions[p]["info_plist"]) - macos_entitlements.update( - self.cross_platform_permissions[p]["macos_entitlements"] - ) - android_permissions.update( - self.cross_platform_permissions[p]["android_permissions"] - ) - android_features.update( - self.cross_platform_permissions[p]["android_features"] - ) - - info_plist = merge_dict( - info_plist, - ( - self.get_pyproject("tool.flet.macos.info") - if self.package_platform == "Darwin" - else self.get_pyproject("tool.flet.ios.info") - ) - or {}, - ) - - # parse --info-plist - for p in self.options.info_plist: - i = p.find("=") - if i > -1: - k = p[:i] - v = p[i + 1 :] - info_plist[k] = True if v == "True" else False if v == "False" else v - else: - self.cleanup(1, f"Invalid Info.plist option: {p}") - - macos_entitlements = merge_dict( - macos_entitlements, - self.get_pyproject("tool.flet.macos.entitlement") or {}, - ) - - # parse --macos-entitlements - for p in self.options.macos_entitlements: - i = p.find("=") - if i > -1: - macos_entitlements[p[:i]] = p[i + 1 :] == "True" - else: - self.cleanup(1, f"Invalid macOS entitlement option: {p}") - - android_permissions = merge_dict( - android_permissions, - self.get_pyproject("tool.flet.android.permission") or {}, - ) - - # parse --android-permissions - for p in self.options.android_permissions: - i = p.find("=") - if i > -1: - android_permissions[p[:i]] = p[i + 1 :] == "True" - else: - self.cleanup(1, f"Invalid Android permission option: {p}") - - android_features = merge_dict( - android_features, - self.get_pyproject("tool.flet.android.feature") or {}, - ) - - # parse --android-features - for p in self.options.android_features: - i = p.find("=") - if i > -1: - android_features[p[:i]] = p[i + 1 :] == "True" - else: - self.cleanup(1, f"Invalid Android feature option: {p}") - - android_meta_data = merge_dict( - android_meta_data, - self.get_pyproject("tool.flet.android.meta_data") or {}, - ) - - # parse --android-meta-data - for p in self.options.android_meta_data: - i = p.find("=") - if i > -1: - android_meta_data[p[:i]] = p[i + 1 :] - else: - self.cleanup(1, f"Invalid Android meta-data option: {p}") - - deep_linking_scheme = ( - self.get_pyproject("tool.flet.ios.deep_linking.scheme") - if self.package_platform == "iOS" - else ( - self.get_pyproject("tool.flet.android.deep_linking.scheme") - if self.package_platform == "Android" - else self.get_pyproject("tool.flet.deep_linking.scheme") - ) - ) - - deep_linking_host = ( - self.get_pyproject("tool.flet.ios.deep_linking.host") - if self.package_platform == "iOS" - else ( - self.get_pyproject("tool.flet.android.deep_linking.host") - if self.package_platform == "Android" - else self.get_pyproject("tool.flet.deep_linking.host") - ) - ) - - if self.options.deep_linking_scheme and self.options.deep_linking_host: - deep_linking_scheme = self.options.deep_linking_scheme - deep_linking_host = self.options.deep_linking_host - - target_arch = ( - self.options.target_arch - or self.get_pyproject(f"tool.flet.{self.config_platform}.target_arch") - or self.get_pyproject("tool.flet.target_arch") - ) - - ios_export_method = ( - self.options.ios_export_method - or self.get_pyproject("tool.flet.ios.export_method") - or "debugging" - ) - - ios_export_method_opts = ( - self.get_pyproject("tool.flet.ios.export_methods").get(ios_export_method) - if self.get_pyproject("tool.flet.ios.export_methods") - else {} - ) or {} - - ios_provisioning_profile = ( - self.options.ios_provisioning_profile - or self.get_pyproject("tool.flet.ios.provisioning_profile") - or ios_export_method_opts.get("provisioning_profile") - ) - - ios_signing_certificate = ( - self.options.ios_signing_certificate - or self.get_pyproject("tool.flet.ios.signing_certificate") - or ios_export_method_opts.get("signing_certificate") - ) - - ios_export_options = ( - self.get_pyproject("tool.flet.ios.export_options") - or ios_export_method_opts.get("export_options") - or {} - ) - - ios_team_id = ( - self.options.ios_team_id - or self.get_pyproject("tool.flet.ios.team_id") - or ios_export_method_opts.get("team_id") - ) - - if ( - self.target_platform in ["ipa"] - and not ios_provisioning_profile - and not self.debug_platform - ): - console.print( - Panel( - "This build will generate an .xcarchive (Xcode Archive). " - "To produce an .ipa (iOS App Package), please specify " - "a Provisioning Profile.", - style=warning_style, - ) - ) - - assert self.flutter_dir - self.template_data = { - "out_dir": self.flutter_dir.name, - "sep": os.sep, - "python_module_name": self.python_module_name, - "route_url_strategy": ( - self.options.route_url_strategy - or self.get_pyproject("tool.flet.web.route_url_strategy") - or "path" - ), - "web_renderer": ( - self.options.web_renderer - or self.get_pyproject("tool.flet.web.renderer") - or "auto" - ), - "pwa_background_color": ( - self.options.pwa_background_color - or self.get_pyproject("tool.flet.web.pwa_background_color") - ), - "pwa_theme_color": ( - self.options.pwa_theme_color - or self.get_pyproject("tool.flet.web.pwa_theme_color") - ), - "no_wasm": ( - self.options.no_wasm - or self.get_pyproject("tool.flet.web.wasm") == False # noqa: E712 - ), - "no_cdn": ( - self.options.no_cdn or self.get_pyproject("tool.flet.web.cdn") == False # noqa: E712 - ), - "base_url": f"/{base_url}/" if base_url else "/", - "split_per_abi": split_per_abi, - "project_name": project_name, - "project_name_slug": project_name_slug, - "product_name": product_name, - "description": ( - self.options.description - or self.get_pyproject("project.description") - or self.get_pyproject("tool.poetry.description") - ), - "org_name": self.options.org_name - or self.get_pyproject(f"tool.flet.{self.config_platform}.org") - or self.get_pyproject("tool.flet.org"), - "bundle_id": self.options.bundle_id - or self.get_pyproject(f"tool.flet.{self.config_platform}.bundle_id") - or self.get_pyproject("tool.flet.bundle_id"), - "company_name": ( - self.options.company_name or self.get_pyproject("tool.flet.company") - ), - "copyright": self.options.copyright - or self.get_pyproject("tool.flet.copyright"), - "ios_export_method": ios_export_method, - "ios_provisioning_profile": ios_provisioning_profile, - "ios_signing_certificate": ios_signing_certificate, - "ios_export_options": ios_export_options, - "ios_team_id": ios_team_id, - "options": { - "package_platform": self.package_platform, - "config_platform": self.config_platform, - "target_arch": ( - target_arch - if isinstance(target_arch, list) - else [target_arch] - if isinstance(target_arch, str) - else [] - ), - "info_plist": info_plist, - "macos_entitlements": macos_entitlements, - "android_permissions": android_permissions, - "android_features": android_features, - "android_meta_data": android_meta_data, - "deep_linking": { - "scheme": deep_linking_scheme, - "host": deep_linking_host, - }, - "android_signing": self.options.android_signing_key_store is not None, - }, - "flutter": {"dependencies": list(self.flutter_dependencies.keys())}, - "pyproject": self.get_pyproject(), - } - - def create_flutter_project(self, second_pass=False): - assert self.options - assert self.get_pyproject - assert self.flutter_dir - assert self.template_data - assert self.build_dir - assert self.pubspec_path - - hash = HashStamp( - self.build_dir / ".hash" / f"template-{'2' if second_pass else '1'}" - ) - - template_url = ( - self.options.template - or self.get_pyproject("tool.flet.template.url") - or DEFAULT_TEMPLATE_URL - ) - hash.update(template_url) - - template_ref = self.options.template_ref or self.get_pyproject( - "tool.flet.template.ref" - ) - if not template_ref: - template_ref = ( - version.Version(flet.version.version).base_version - if flet.version.version - else update_version() - ) - hash.update(template_ref) - - template_dir = self.options.template_dir or self.get_pyproject( - "tool.flet.template.dir" - ) - hash.update(template_dir) - hash.update(self.template_data) - - hash_changed = hash.has_changed() - - if hash_changed: - # if options.clear_cache is set, delete any existing Flutter bootstrap - # project directory - if ( - self.options.clear_cache - and self.flutter_dir.exists() - and not second_pass - ): - if self.verbose > 1: - console.log(f"Deleting {self.flutter_dir}", style=verbose2_style) - shutil.rmtree(self.flutter_dir, ignore_errors=True) - - # create a new Flutter bootstrap project directory, if non-existent - if not second_pass: - self.flutter_dir.mkdir(parents=True, exist_ok=True) - self.update_status( - "[bold blue]Creating Flutter bootstrap project from " - f'{template_url} with ref "{template_ref}"...' - ) - - try: - from cookiecutter.main import cookiecutter - - cookiecutter( - template=template_url, - checkout=template_ref, - directory=template_dir, - output_dir=str(self.flutter_dir.parent), - no_input=True, - overwrite_if_exists=True, - extra_context={ - k: v for k, v in self.template_data.items() if v is not None - }, - ) - except Exception as e: - shutil.rmtree(self.flutter_dir) - self.cleanup(1, f"{e}") - - pyproject_pubspec = self.get_pyproject("tool.flet.flutter.pubspec") - - if pyproject_pubspec: - pubspec = self.load_yaml(self.pubspec_path) - pubspec = merge_dict(pubspec, pyproject_pubspec) - self.save_yaml(self.pubspec_path, pubspec) - - # make backup of pubspec.yaml - shutil.copyfile(self.pubspec_path, f"{self.pubspec_path}.orig") - - if not second_pass: - console.log( - f"Created Flutter bootstrap project from {template_url} " - f'with ref "{template_ref}" {self.emojis["checkmark"]}' - ) - - hash.commit() - - return hash_changed - - def register_flutter_extensions(self): - assert self.flutter_packages_dir - assert self.flutter_packages_temp_dir - assert isinstance(self.flutter_dependencies, dict) - assert self.template_data - assert self.build_dir - - if self.flutter_packages_temp_dir.exists(): - # copy packages from temp to permanent location - if self.flutter_packages_dir.exists(): - shutil.rmtree(self.flutter_packages_dir, ignore_errors=True) - shutil.move(self.flutter_packages_temp_dir, self.flutter_packages_dir) - - if self.flutter_packages_dir.exists(): - self.update_status("[bold blue]Registering Flutter user extensions...") - - for fp in os.listdir(self.flutter_packages_dir): - if (self.flutter_packages_dir / fp / "pubspec.yaml").exists(): - ext_dir = str(self.flutter_packages_dir / fp) - if self.verbose > 0: - console.log(f"Found Flutter extension at {ext_dir}") - self.flutter_dependencies[fp] = {"path": ext_dir} - - self.template_data["flutter"]["dependencies"] = list( - self.flutter_dependencies.keys() - ) - - console.log( - f"Registered Flutter user extensions {self.emojis['checkmark']}" - ) - - def update_flutter_dependencies(self): - assert self.pubspec_path - assert self.template_data - assert self.get_pyproject - assert self.build_dir - assert isinstance(self.flutter_dependencies, dict) - - pubspec = self.load_yaml(self.pubspec_path) - - # merge dependencies to a dest pubspec.yaml - for k, v in self.flutter_dependencies.items(): - pubspec["dependencies"][k] = v - - # make sure project_name is not named as any of the dependencies - for dep in pubspec["dependencies"]: - if dep == self.template_data["project_name"]: - self.cleanup( - 1, - f"Project name cannot have the same name as one of its " - f"dependencies: {dep}. Use --project option to specify " - "a different project name.", - ) - - self.save_yaml(self.pubspec_path, pubspec) - - def customize_icons(self): - assert self.package_app_path - assert self.flutter_dir - assert self.options - assert self.get_pyproject - assert self.pubspec_path - assert self.build_dir - - hash = HashStamp(self.build_dir / ".hash" / "icons") - - pubspec_origin_path = f"{self.pubspec_path}.orig" - pubspec = self.load_yaml(pubspec_origin_path) - - copy_ops = [] - self.assets_path = self.package_app_path.joinpath("assets") - if self.assets_path.exists(): - images_dir = "images" - images_path = self.flutter_dir.joinpath(images_dir) - images_path.mkdir(exist_ok=True) - - # copy icons - default_icon = self.find_platform_image( - self.assets_path, images_path, "icon", copy_ops, hash - ) - ios_icon = self.find_platform_image( - self.assets_path, images_path, "icon_ios", copy_ops, hash - ) - android_icon = self.find_platform_image( - self.assets_path, images_path, "icon_android", copy_ops, hash - ) - web_icon = self.find_platform_image( - self.assets_path, images_path, "icon_web", copy_ops, hash - ) - windows_icon = self.find_platform_image( - self.assets_path, images_path, "icon_windows", copy_ops, hash - ) - macos_icon = self.find_platform_image( - self.assets_path, images_path, "icon_macos", copy_ops, hash - ) - - self.fallback_image( - pubspec, "flutter_launcher_icons.image_path", [default_icon], images_dir - ) - self.fallback_image( - pubspec, - "flutter_launcher_icons.image_path_ios", - [ios_icon, default_icon], - images_dir, - ) - self.fallback_image( - pubspec, - "flutter_launcher_icons.image_path_android", - [android_icon, default_icon], - images_dir, - ) - self.fallback_image( - pubspec, - "flutter_launcher_icons.adaptive_icon_foreground", - [android_icon, default_icon], - images_dir, - ) - self.fallback_image( - pubspec, - "flutter_launcher_icons.web.image_path", - [web_icon, default_icon], - images_dir, - ) - self.fallback_image( - pubspec, - "flutter_launcher_icons.windows.image_path", - [windows_icon, default_icon], - images_dir, - ) - self.fallback_image( - pubspec, - "flutter_launcher_icons.macos.image_path", - [macos_icon, default_icon], - images_dir, - ) - - adaptive_icon_background = ( - self.options.android_adaptive_icon_background - or self.get_pyproject("tool.flet.android.adaptive_icon_background") - ) - if adaptive_icon_background: - pubspec["flutter_launcher_icons"]["adaptive_icon_background"] = ( - adaptive_icon_background - ) - - # check if pubspec changed - hash.update(Path(pubspec_origin_path).stat().st_mtime) - hash.update(pubspec["flutter_launcher_icons"]) - - # save pubspec.yaml - if hash.has_changed(): - if copy_ops: - self.update_status("[bold blue]Customizing app icons...") - for op in copy_ops: - if self.verbose > 0: - console.log( - f"Copying image {op[0]} to {op[1]}", style=verbose1_style - ) - shutil.copy(op[0], op[1]) - console.log(f"Customized app icons {self.emojis['checkmark']}") - - updated_pubspec = self.load_yaml(self.pubspec_path) - updated_pubspec["flutter_launcher_icons"] = pubspec[ - "flutter_launcher_icons" - ] - self.save_yaml(self.pubspec_path, updated_pubspec) - - self.update_status("[bold blue]Generating app icons...") - - # icons - icons_result = self.run( - [ - self.dart_exe, - "run", - "--suppress-analytics", - "flutter_launcher_icons", - ], - cwd=str(self.flutter_dir), - capture_output=self.verbose < 1, - ) - if icons_result.returncode != 0: - if icons_result.stdout: - console.log(icons_result.stdout, style=verbose1_style) - if icons_result.stderr: - console.log(icons_result.stderr, style=error_style) - self.cleanup(icons_result.returncode) - console.log(f"Generated app icons {self.emojis['checkmark']}") - - hash.commit() - - def customize_splash_images(self): - assert self.package_app_path - assert self.flutter_dir - assert self.options - assert self.get_pyproject - assert self.pubspec_path - assert self.build_dir - assert self.target_platform - - if self.target_platform not in ["web", "ipa", "apk", "aab"]: - return - - hash = HashStamp(self.build_dir / ".hash" / "splashes") - - pubspec_origin_path = f"{self.pubspec_path}.orig" - - pubspec = self.load_yaml(pubspec_origin_path) - - copy_ops = [] - self.assets_path = self.package_app_path.joinpath("assets") - if self.assets_path.exists(): - images_dir = "images" - images_path = self.flutter_dir.joinpath(images_dir) - images_path.mkdir(exist_ok=True) - - # copy icons - default_icon = self.find_platform_image( - self.assets_path, images_path, "icon", copy_ops, hash - ) - - # copy splash images - default_splash = self.find_platform_image( - self.assets_path, images_path, "splash", copy_ops, hash - ) - default_dark_splash = self.find_platform_image( - self.assets_path, images_path, "splash_dark", copy_ops, hash - ) - ios_splash = self.find_platform_image( - self.assets_path, images_path, "splash_ios", copy_ops, hash - ) - ios_dark_splash = self.find_platform_image( - self.assets_path, images_path, "splash_dark_ios", copy_ops, hash - ) - android_splash = self.find_platform_image( - self.assets_path, images_path, "splash_android", copy_ops, hash - ) - android_dark_splash = self.find_platform_image( - self.assets_path, images_path, "splash_dark_android", copy_ops, hash - ) - web_splash = self.find_platform_image( - self.assets_path, images_path, "splash_web", copy_ops, hash - ) - web_dark_splash = self.find_platform_image( - self.assets_path, images_path, "splash_dark_web", copy_ops, hash - ) - self.fallback_image( - pubspec, - "flutter_native_splash.image", - [default_splash, default_icon], - images_dir, - ) - self.fallback_image( - pubspec, - "flutter_native_splash.image_dark", - [default_dark_splash, default_splash, default_icon], - images_dir, - ) - self.fallback_image( - pubspec, - "flutter_native_splash.image_ios", - [ios_splash, default_splash, default_icon], - images_dir, - ) - self.fallback_image( - pubspec, - "flutter_native_splash.image_dark_ios", - [ - ios_dark_splash, - default_dark_splash, - ios_splash, - default_splash, - default_icon, - ], - images_dir, - ) - self.fallback_image( - pubspec, - "flutter_native_splash.image_android", - [android_splash, default_splash, default_icon], - images_dir, - ) - self.fallback_image( - pubspec, - "flutter_native_splash.android_12.image", - [android_splash, default_splash, default_icon], - images_dir, - ) - self.fallback_image( - pubspec, - "flutter_native_splash.image_dark_android", - [ - android_dark_splash, - default_dark_splash, - android_splash, - default_splash, - default_icon, - ], - images_dir, - ) - self.fallback_image( - pubspec, - "flutter_native_splash.android_12.image_dark", - [ - android_dark_splash, - default_dark_splash, - android_splash, - default_splash, - default_icon, - ], - images_dir, - ) - self.fallback_image( - pubspec, - "flutter_native_splash.image_web", - [web_splash, default_splash, default_icon], - images_dir, - ) - self.fallback_image( - pubspec, - "flutter_native_splash.image_dark_web", - [ - web_dark_splash, - default_dark_splash, - web_splash, - default_splash, - default_icon, - ], - images_dir, - ) - - # splash colors - splash_color = ( - self.options.splash_color - or self.get_pyproject(f"tool.flet.{self.config_platform}.splash.color") - or self.get_pyproject("tool.flet.splash.color") - ) - if splash_color: - pubspec["flutter_native_splash"]["color"] = splash_color - pubspec["flutter_native_splash"]["android_12"]["color"] = splash_color - - splash_dark_color = ( - self.options.splash_dark_color - or self.get_pyproject(f"tool.flet.{self.config_platform}.splash.dark_color") - or self.get_pyproject("tool.flet.splash.dark_color") - ) - if splash_dark_color: - pubspec["flutter_native_splash"]["color_dark"] = splash_dark_color - pubspec["flutter_native_splash"]["android_12"]["color_dark"] = ( - splash_dark_color - ) - - splash_icon_bgcolor = self.get_pyproject( - f"tool.flet.{self.config_platform}.splash.icon_bgcolor" - ) or self.get_pyproject("tool.flet.splash.icon_bgcolor") - - if splash_icon_bgcolor: - pubspec["flutter_native_splash"]["android_12"]["icon_background_color"] = ( - splash_icon_bgcolor - ) - - splash_icon_dark_bgcolor = self.get_pyproject( - f"tool.flet.{self.config_platform}.splash.icon_dark_bgcolor" - ) or self.get_pyproject("tool.flet.splash.icon_dark_bgcolor") - - if splash_icon_dark_bgcolor: - pubspec["flutter_native_splash"]["android_12"][ - "icon_background_color_dark" - ] = splash_icon_dark_bgcolor - - # enable/disable splashes - pubspec["flutter_native_splash"]["web"] = ( - not self.options.no_web_splash - if self.options.no_web_splash is not None - else ( - self.get_pyproject("tool.flet.splash.web") - if self.get_pyproject("tool.flet.splash.web") is not None - else True - ) - ) - pubspec["flutter_native_splash"]["ios"] = ( - not self.options.no_ios_splash - if self.options.no_ios_splash is not None - else ( - self.get_pyproject("tool.flet.splash.ios") - if self.get_pyproject("tool.flet.splash.ios") is not None - else True - ) - ) - pubspec["flutter_native_splash"]["android"] = ( - not self.options.no_android_splash - if self.options.no_android_splash is not None - else ( - self.get_pyproject("tool.flet.splash.android") - if self.get_pyproject("tool.flet.splash.android") is not None - else True - ) - ) - - # check if pubspec changed - hash.update(Path(pubspec_origin_path).stat().st_mtime) - hash.update(pubspec["flutter_native_splash"]) - - # save pubspec.yaml - if hash.has_changed(): - if copy_ops: - self.update_status("[bold blue]Customizing app splash images...") - for op in copy_ops: - if self.verbose > 0: - console.log( - f"Copying image {op[0]} to {op[1]}", style=verbose1_style - ) - shutil.copy(op[0], op[1]) - console.log(f"Customized app splash images {self.emojis['checkmark']}") - - updated_pubspec = self.load_yaml(self.pubspec_path) - updated_pubspec["flutter_native_splash"] = pubspec["flutter_native_splash"] - self.save_yaml(self.pubspec_path, updated_pubspec) - - # splash screens - self.update_status("[bold blue]Generating splash screens...") - splash_result = self.run( - [ - self.dart_exe, - "run", - "--suppress-analytics", - "flutter_native_splash:create", - ], - cwd=str(self.flutter_dir), - capture_output=self.verbose < 1, - ) - if splash_result.returncode != 0: - if splash_result.stdout: - console.log(splash_result.stdout, style=verbose1_style) - if splash_result.stderr: - console.log(splash_result.stderr, style=error_style) - self.cleanup(splash_result.returncode) - console.log(f"Generated splash screens {self.emojis['checkmark']}") - - hash.commit() - - def fallback_image(self, pubspec, yaml_path: str, images: list, images_dir: str): - d = pubspec - pp = yaml_path.split(".") - for p in pp[:-1]: - d = d[p] - for image in images: - if image: - d[pp[-1]] = f"{images_dir}/{image}" - return - - def package_python_app(self): - assert self.options - assert self.get_pyproject - assert self.python_app_path - assert self.package_app_path - assert self.build_dir - assert self.flutter_dir - assert self.flutter_packages_dir - assert self.flutter_packages_temp_dir - assert self.template_data - - hash = HashStamp(self.build_dir / ".hash" / "package") - - self.update_status("[bold blue]Packaging Python app...") - package_args = [ - self.dart_exe, - "run", - "--suppress-analytics", - "serious_python:main", - "package", - str(self.package_app_path), - "--platform", - self.package_platform, - ] - - if self.template_data["options"]["target_arch"]: - package_args.extend( - ["--arch"] + self.template_data["options"]["target_arch"] - ) - - package_env = {} - - # requirements - requirements_txt = self.python_app_path.joinpath("requirements.txt") - - toml_dependencies = ( - get_poetry_dependencies(self.get_pyproject("tool.poetry.dependencies")) - or get_project_dependencies(self.get_pyproject("project.dependencies")) - or [] - ) - - platform_dependencies = get_project_dependencies( - self.get_pyproject(f"tool.flet.{self.config_platform}.dependencies") - ) - if platform_dependencies: - toml_dependencies.extend(platform_dependencies) - - dev_packages_configured = False - if len(toml_dependencies) > 0: - dev_packages = ( - self.get_pyproject(f"tool.flet.{self.config_platform}.dev_packages") - or self.get_pyproject("tool.flet.dev_packages") - or [] - ) - if len(dev_packages) > 0: - for i in range(0, len(toml_dependencies)): - package_name = Requirement(toml_dependencies[i]).name - if package_name in dev_packages: - package_location = dev_packages[package_name] - dev_path = Path(package_location) - if not dev_path.is_absolute(): - dev_path = (self.python_app_path / dev_path).resolve() - if dev_path.exists(): - toml_dependencies[i] = f"{package_name} @ file://{dev_path}" - else: - toml_dependencies[i] = ( - f"{package_name} @ {package_location}" - ) - dev_packages_configured = True - if dev_packages_configured: - toml_dependencies.append("--no-cache-dir") - - for toml_dep in toml_dependencies: - package_args.extend(["-r", toml_dep]) - - elif requirements_txt.exists(): - if self.verbose > 1: - with open(requirements_txt, encoding="utf-8") as f: - reqs_txt_contents = f.read() - console.log( - f"Contents of requirements.txt: {reqs_txt_contents}", - style=verbose2_style, - ) - hash.update(reqs_txt_contents) - package_args.extend(["-r", "-r", "-r", str(requirements_txt)]) - else: - flet_version = ( - flet.version.version if flet.version.version else update_version() - ) - package_args.extend(["-r", f"flet=={flet_version}"]) - - # site-packages variable - if self.package_platform != "Pyodide": - package_env["SERIOUS_PYTHON_SITE_PACKAGES"] = str( - self.build_dir / "site-packages" - ) - - # flutter-packages variable - if self.flutter_packages_temp_dir.exists(): - shutil.rmtree(self.flutter_packages_temp_dir) - - package_env["SERIOUS_PYTHON_FLUTTER_PACKAGES"] = str( - self.flutter_packages_temp_dir - ) - - # exclude - exclude_list = ["build"] - - app_exclude = ( - self.options.exclude - or self.get_pyproject(f"tool.flet.{self.config_platform}.app.exclude") - or self.get_pyproject("tool.flet.app.exclude") - ) - if app_exclude: - exclude_list.extend(app_exclude) - - if self.target_platform == "web": - exclude_list.append("assets") - package_args.extend(["--exclude", ",".join(exclude_list)]) - - # source-packages - source_packages = ( - self.options.source_packages - or self.get_pyproject(f"tool.flet.{self.config_platform}.source_packages") - or self.get_pyproject("tool.flet.source_packages") - ) - if source_packages: - package_env["SERIOUS_PYTHON_ALLOW_SOURCE_DISTRIBUTIONS"] = ",".join( - source_packages - ) - - if self.get_bool_setting(self.options.compile_app, "compile.app", False): - package_args.append("--compile-app") - - if self.get_bool_setting( - self.options.compile_packages, "compile.packages", False - ): - package_args.append("--compile-packages") - - cleanup_app = self.get_bool_setting( - self.options.cleanup_app, "cleanup.app", False - ) - cleanup_packages = self.get_bool_setting( - self.options.cleanup_packages, "cleanup.packages", True - ) - - # TODO: should be deprecated - if self.get_bool_setting(None, "compile.cleanup", False): - cleanup_app = cleanup_packages = True - - if cleanup_app_files := ( - self.options.cleanup_app_files - or self.get_pyproject(f"tool.flet.{self.config_platform}.cleanup.app_files") - or self.get_pyproject("tool.flet.cleanup.app_files") - ): - package_args.extend(["--cleanup-app-files", ",".join(cleanup_app_files)]) - cleanup_app = True - - if cleanup_package_files := ( - self.options.cleanup_package_files - or self.get_pyproject( - f"tool.flet.{self.config_platform}.cleanup.package_files" - ) - or self.get_pyproject("tool.flet.cleanup.package_files") - ): - package_args.extend( - ["--cleanup-package-files", ",".join(cleanup_package_files)] - ) - cleanup_packages = True - - if cleanup_app: - package_args.append("--cleanup-app") - - if cleanup_packages: - package_args.append("--cleanup-packages") - - if self.verbose > 1: - package_args.append("--verbose") - - # check if site-packages installation could be skipped - for arg in package_args: - hash.update(arg) - - if not dev_packages_configured: - if not hash.has_changed(): - package_args.append("--skip-site-packages") - else: - if self.flutter_packages_dir.exists(): - shutil.rmtree(self.flutter_packages_dir, ignore_errors=True) - - package_result = self.run( - package_args, - cwd=str(self.flutter_dir), - env=package_env, - capture_output=self.verbose < 1, - ) - - if package_result.returncode != 0: - if package_result.stdout: - console.log(package_result.stdout, style=verbose1_style) - if package_result.stderr: - console.log(package_result.stderr, style=error_style) - self.cleanup(package_result.returncode) - - hash.commit() - - # make sure app/app.zip exists - app_zip_path = self.flutter_dir.joinpath("app", "app.zip") - if not os.path.exists(app_zip_path): - self.cleanup(1, "Flet app package app/app.zip was not created.") - - console.log(f"Packaged Python app {self.emojis['checkmark']}") - - def get_bool_setting(self, cli_option, pyproj_setting, default_value): - assert self.get_pyproject - return ( - cli_option - if cli_option is not None - else ( - self.get_pyproject(f"tool.flet.{self.config_platform}.{pyproj_setting}") - if self.get_pyproject( - f"tool.flet.{self.config_platform}.{pyproj_setting}" - ) - is not None - else ( - self.get_pyproject(f"tool.flet.{pyproj_setting}") - if self.get_pyproject(f"tool.flet.{pyproj_setting}") is not None - else default_value - ) - ) - ) - - def add_flutter_command_args(self, args: list[str]): - pass - - def run_flutter(self): - self._run_flutter_command() - - def _run_flutter_command(self): - assert self.options - assert self.build_dir - assert self.get_pyproject - assert self.template_data - assert self.target_platform - - # flutter build - build_args = [self.flutter_exe] - self.add_flutter_command_args(build_args) - build_args.extend( - [ - "--no-version-check", - "--suppress-analytics", - ] - ) - - build_env = {} - - # site-packages variable - if self.package_platform != "Pyodide": - build_env["SERIOUS_PYTHON_SITE_PACKAGES"] = str( - self.build_dir / "site-packages" - ) - - if self.package_platform == "Pyodide" and not self.template_data["no_wasm"]: - build_args.append("--wasm") - - android_signing_key_store = ( - self.options.android_signing_key_store - or self.get_pyproject("tool.flet.android.signing.key_store") - ) - if android_signing_key_store: - build_env["FLET_ANDROID_SIGNING_KEY_STORE"] = android_signing_key_store - - key_store_password = ( - self.options.android_signing_key_store_password - or os.getenv("FLET_ANDROID_SIGNING_KEY_STORE_PASSWORD") - ) - key_password = self.options.android_signing_key_password or os.getenv( - "FLET_ANDROID_SIGNING_KEY_PASSWORD" - ) - if key_store_password or key_password: - build_env["FLET_ANDROID_SIGNING_KEY_STORE_PASSWORD"] = ( - key_store_password if key_store_password else key_password - ) - build_env["FLET_ANDROID_SIGNING_KEY_PASSWORD"] = ( - key_password if key_password else key_store_password - ) - - android_signing_key_alias = ( - self.options.android_signing_key_alias - or self.get_pyproject("tool.flet.android.signing.key_alias") - ) - if android_signing_key_alias: - build_env["FLET_ANDROID_SIGNING_KEY_ALIAS"] = android_signing_key_alias - - if self.options.flutter_build_args: - for flutter_build_arg_arr in self.options.flutter_build_args: - build_args.extend(flutter_build_arg_arr) - - if self.verbose > 1: - build_args.append("--verbose") - - build_result = self.run( - build_args, - cwd=str(self.flutter_dir), - env=build_env, - capture_output=self.verbose < 1, - ) - - if ( - build_result.returncode != 0 - or "Encountered error while creating the IPA" in str(build_result.stderr) - ): - if build_result.stdout: - console.log(build_result.stdout, style=verbose1_style) - if build_result.stderr: - console.log(build_result.stderr, style=error_style) - self.cleanup(build_result.returncode if build_result.returncode else 1) - - def copy_build_output(self): - assert self.template_data - assert self.options - assert self.flutter_dir - assert self.out_dir - assert self.assets_path - assert self.target_platform - - self.update_status( - f"[bold blue]Copying build to [cyan]{self.rel_out_dir}[/cyan] directory...", - ) - arch = platform.machine().lower() - if arch in {"x86_64", "amd64"}: - arch = "x64" - elif arch in {"arm64", "aarch64"}: - arch = "arm64" - - def make_ignore_fn(out_dir, out_glob): - def ignore(path, names): - if path == out_dir and out_glob != "*": - return [f for f in os.listdir(path) if f != out_glob] - return [] - - return ignore - - for build_output in self.platforms[self.target_platform]["outputs"]: - build_output_dir = ( - str(self.flutter_dir.joinpath(build_output)) - .replace("{arch}", arch) - .replace("{project_name}", self.template_data["project_name"]) - .replace("{product_name}", self.template_data["product_name"]) - ) - - if self.verbose > 0: - console.log( - "Copying build output from: " + build_output_dir, - style=verbose1_style, - ) - - build_output_glob = os.path.basename(build_output_dir) - build_output_dir = os.path.dirname(build_output_dir) - if not os.path.exists(build_output_dir): - continue - - if self.out_dir.exists(): - shutil.rmtree(str(self.out_dir)) - self.out_dir.mkdir(parents=True, exist_ok=True) - - # copy build result to out_dir - copy_tree( - build_output_dir, - str(self.out_dir), - ignore=make_ignore_fn(build_output_dir, build_output_glob), - ) - - if self.target_platform == "web" and self.assets_path.exists(): - # copy `assets` directory contents to the output directory - copy_tree(str(self.assets_path), str(self.out_dir)) - - console.log( - f"Copied build to [cyan]{self.rel_out_dir}[/cyan] " - f"directory {self.emojis['checkmark']}" - ) - - def find_platform_image( - self, - src_path: Path, - dest_path: Path, - image_name: str, - copy_ops: list, - hash: HashStamp, - ): - images = glob.glob(str(src_path.joinpath(f"{image_name}.*"))) - if len(images) > 0: - if self.verbose > 0: - console.log( - f'Found "{image_name}" image at {images[0]}', style=verbose1_style - ) - copy_ops.append((images[0], dest_path)) - hash.update(images[0]) - hash.update(Path(images[0]).stat().st_mtime) - return Path(images[0]).name - return None - def find_flutter_batch(self, exe_filename: str): batch_path = shutil.which(exe_filename) if not batch_path: @@ -2184,11 +290,3 @@ def log_stdout(self, message): style=verbose2_style, markup=False, ) - - def load_yaml(self, path): - with open(str(path), encoding="utf-8") as f: - return yaml.safe_load(f) - - def save_yaml(self, path, doc): - with open(str(path), "w", encoding="utf-8") as f: - yaml.dump(doc, f) From 0a23cc7982cc0316301af04f7c6de9385c0431c8 Mon Sep 17 00:00:00 2001 From: Feodor Fitsner Date: Wed, 26 Nov 2025 12:30:18 -0800 Subject: [PATCH 06/22] Update minimal Flutter version to 3.38.3 Bump the required Flutter version from 3.38.2 to 3.38.3 in both .fvmrc and flutter_base.py to ensure compatibility with the latest release. --- .fvmrc | 2 +- .../packages/flet-cli/src/flet_cli/commands/flutter_base.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.fvmrc b/.fvmrc index dceb80ddb..9bf4ad97a 100644 --- a/.fvmrc +++ b/.fvmrc @@ -1,3 +1,3 @@ { - "flutter": "3.38.2" + "flutter": "3.38.3" } diff --git a/sdk/python/packages/flet-cli/src/flet_cli/commands/flutter_base.py b/sdk/python/packages/flet-cli/src/flet_cli/commands/flutter_base.py index 71375bb49..982f7b396 100644 --- a/sdk/python/packages/flet-cli/src/flet_cli/commands/flutter_base.py +++ b/sdk/python/packages/flet-cli/src/flet_cli/commands/flutter_base.py @@ -20,7 +20,7 @@ PYODIDE_ROOT_URL = "https://cdn.jsdelivr.net/pyodide/v0.27.7/full" DEFAULT_TEMPLATE_URL = "gh:flet-dev/flet-build-template" -MINIMAL_FLUTTER_VERSION = version.Version("3.38.2") +MINIMAL_FLUTTER_VERSION = version.Version("3.38.3") no_rich_output = get_bool_env_var("FLET_CLI_NO_RICH_OUTPUT") From 96739fba53780fed86fa797dc59263fa92f2de97 Mon Sep 17 00:00:00 2001 From: Feodor Fitsner Date: Wed, 26 Nov 2025 13:27:08 -0800 Subject: [PATCH 07/22] Add devices command and refactor build/debug initialization Introduces a new 'devices' command to list connected devices via Flutter. Refactors build and debug commands to use a unified 'initialize_command' method, moving progress and verbose handling to the base class. Updates CLI registration to include the new devices command. --- .../packages/flet-cli/src/flet_cli/cli.py | 2 + .../flet-cli/src/flet_cli/commands/build.py | 6 +- .../src/flet_cli/commands/build_base.py | 23 ++--- .../flet-cli/src/flet_cli/commands/debug.py | 8 +- .../flet-cli/src/flet_cli/commands/devices.py | 89 +++++++++++++++++++ .../src/flet_cli/commands/flutter_base.py | 12 ++- 6 files changed, 107 insertions(+), 33 deletions(-) create mode 100644 sdk/python/packages/flet-cli/src/flet_cli/commands/devices.py diff --git a/sdk/python/packages/flet-cli/src/flet_cli/cli.py b/sdk/python/packages/flet-cli/src/flet_cli/cli.py index ffb4b925a..00eb3d662 100644 --- a/sdk/python/packages/flet-cli/src/flet_cli/cli.py +++ b/sdk/python/packages/flet-cli/src/flet_cli/cli.py @@ -5,6 +5,7 @@ import flet_cli.commands.build import flet_cli.commands.create import flet_cli.commands.debug +import flet_cli.commands.devices import flet_cli.commands.doctor # Adding the doctor command import flet_cli.commands.pack import flet_cli.commands.publish @@ -83,6 +84,7 @@ def get_parser() -> argparse.ArgumentParser: flet_cli.commands.pack.Command.register_to(sp, "pack") flet_cli.commands.publish.Command.register_to(sp, "publish") flet_cli.commands.serve.Command.register_to(sp, "serve") + flet_cli.commands.devices.Command.register_to(sp, "devices") flet_cli.commands.doctor.Command.register_to(sp, "doctor") # set "run" as the default subparser diff --git a/sdk/python/packages/flet-cli/src/flet_cli/commands/build.py b/sdk/python/packages/flet-cli/src/flet_cli/commands/build.py index c7362ab5c..cff84ce6b 100644 --- a/sdk/python/packages/flet-cli/src/flet_cli/commands/build.py +++ b/sdk/python/packages/flet-cli/src/flet_cli/commands/build.py @@ -2,7 +2,6 @@ from rich.console import Group from rich.live import Live -from rich.progress import Progress from flet_cli.commands.build_base import BaseBuildCommand, console @@ -42,11 +41,8 @@ def handle(self, options: argparse.Namespace) -> None: f"[bold blue]Initializing {self.target_platform} build...", spinner="bouncingBall", ) - self.progress = Progress(transient=True) - self.no_rich_output = self.no_rich_output or self.options.no_rich_output - self.verbose = self.options.verbose with Live(Group(self.status, self.progress), console=console) as self.live: - self.initialize_build() + self.initialize_command() self.validate_target_platform() self.validate_entry_point() self.setup_template_data() diff --git a/sdk/python/packages/flet-cli/src/flet_cli/commands/build_base.py b/sdk/python/packages/flet-cli/src/flet_cli/commands/build_base.py index f1cea628f..c5fd6ee44 100644 --- a/sdk/python/packages/flet-cli/src/flet_cli/commands/build_base.py +++ b/sdk/python/packages/flet-cli/src/flet_cli/commands/build_base.py @@ -17,13 +17,11 @@ import flet.version import flet_cli.utils.processes as processes from flet.utils import copy_tree, is_windows, slugify -from flet.utils.platform_utils import get_bool_env_var from flet.version import update_version from flet_cli.commands.flutter_base import ( BaseFlutterCommand, console, error_style, - no_rich_output, verbose1_style, verbose2_style, warning_style, @@ -48,34 +46,22 @@ class BaseBuildCommand(BaseFlutterCommand): def __init__(self, parser: argparse.ArgumentParser) -> None: super().__init__(parser) - self.env = {} self.pubspec_path = None self.rel_out_dir = None self.assets_path = None self.target_platform = None - self.debug_platform = None - self.package_platform = None - self.config_platform = None self.flutter_dependencies = {} self.package_app_path = None - self.options = None self.template_data = None self.python_module_filename = None self.out_dir = None self.python_module_name = None self.get_pyproject = None self.python_app_path = None - self.emojis = {} - self.dart_exe = None - self.verbose = False self.build_dir = None self.flutter_dir: Optional[Path] = None self.flutter_packages_dir = None self.flutter_packages_temp_dir = None - self.flutter_exe = None - self.skip_flutter_doctor = get_bool_env_var("FLET_CLI_SKIP_FLUTTER_DOCTOR") - self.no_rich_output = no_rich_output - self.current_platform = platform.system() self.platforms = { "windows": { "package_platform": "Windows", @@ -607,16 +593,17 @@ def handle(self, options: argparse.Namespace) -> None: if "target_platform" in self.options: self.target_platform = self.options.target_platform - def initialize_build(self): + def initialize_command(self): assert self.options assert self.target_platform - super().initialize_build() - - self.python_app_path = Path(self.options.python_app_path).resolve() self.package_platform = self.platforms[self.target_platform]["package_platform"] self.config_platform = self.platforms[self.target_platform]["config_platform"] + super().initialize_command() + + self.python_app_path = Path(self.options.python_app_path).resolve() + if not ( os.path.exists(self.python_app_path) or os.path.isdir(self.python_app_path) ): diff --git a/sdk/python/packages/flet-cli/src/flet_cli/commands/debug.py b/sdk/python/packages/flet-cli/src/flet_cli/commands/debug.py index a2133a394..60a0557fd 100644 --- a/sdk/python/packages/flet-cli/src/flet_cli/commands/debug.py +++ b/sdk/python/packages/flet-cli/src/flet_cli/commands/debug.py @@ -5,7 +5,6 @@ from rich.console import Group from rich.live import Live -from rich.progress import Progress from flet_cli.commands.build_base import BaseBuildCommand, console, verbose2_style @@ -17,7 +16,6 @@ class Command(BaseBuildCommand): def __init__(self, parser: argparse.ArgumentParser) -> None: super().__init__(parser) - self.debug_platforms = { "windows": {"target_platform": "windows", "device_id": "windows"}, "macos": {"target_platform": "macos", "device_id": "macos"}, @@ -26,6 +24,7 @@ def __init__(self, parser: argparse.ArgumentParser) -> None: "ios": {"target_platform": "ipa", "device_id": None}, "android": {"target_platform": "apk", "device_id": None}, } + self.debug_platform = None self.device_id = None def add_arguments(self, parser: argparse.ArgumentParser) -> None: @@ -86,12 +85,9 @@ def handle(self, options: argparse.Namespace) -> None: f"[bold blue]Initializing {self.target_platform} debug session...", spinner="bouncingBall", ) - self.progress = Progress(transient=True) - self.no_rich_output = self.no_rich_output or self.options.no_rich_output - self.verbose = self.options.verbose with Live(Group(self.status, self.progress), console=console) as self.live: self.check_device_id() - self.initialize_build() + self.initialize_command() if self.options.show_devices: self.run_flutter_devices() self.live.update("", refresh=True) diff --git a/sdk/python/packages/flet-cli/src/flet_cli/commands/devices.py b/sdk/python/packages/flet-cli/src/flet_cli/commands/devices.py new file mode 100644 index 000000000..8b0d84d72 --- /dev/null +++ b/sdk/python/packages/flet-cli/src/flet_cli/commands/devices.py @@ -0,0 +1,89 @@ +import argparse +import os + +from rich.console import Group +from rich.live import Live + +from flet_cli.commands.build_base import BaseFlutterCommand, console, verbose2_style + + +class Command(BaseFlutterCommand): + """ + List all connected devices. + """ + + def __init__(self, parser: argparse.ArgumentParser) -> None: + super().__init__(parser) + self.devices_platform = None + + def add_arguments(self, parser: argparse.ArgumentParser) -> None: + parser.add_argument( + "platform", + type=str, + nargs="?", + choices=["ios", "android", "web"], + help="The target platform to list devices for. " + "If not specified, lists all platforms.", + ) + # parser.add_argument( + # "--device-id", + # "-d", + # type=str, + # dest="device_id", + # help="Device ID to run the app on for iOS and Android builds.", + # ) + # parser.add_argument( + # "--show-devices", + # dest="show_devices", + # action="store_true", + # default=False, + # help="Show connected devices for iOS and Android builds.", + # ) + # parser.add_argument( + # "--release", + # dest="release", + # action="store_true", + # default=False, + # help="Build the app in release mode.", + # ) + # parser.add_argument( + # "--route", + # type=str, + # dest="route", + # help="Route to open the app on for web, iOS and Android builds.", + # ) + super().add_arguments(parser) + + def handle(self, options: argparse.Namespace) -> None: + super().handle(options) + if self.options and "platform" in self.options and self.options.platform: + self.devices_platform = self.options.platform + + self.status = console.status( + "[bold blue]Initializing environment...", + spinner="bouncingBall", + ) + with Live(Group(self.status, self.progress), console=console) as self.live: + self.initialize_command() + # if self.options.show_devices: + # self.run_flutter_devices() + # self.live.update("", refresh=True) + # return + self.run_flutter_devices() + self.cleanup(0, message=("")) + + def initialize_command(self): + self.package_platform = "" + self.config_platform = "" + + super().initialize_command() + + def run_flutter_devices(self): + self.update_status("[bold blue]Checking connected devices...") + flutter_devices = self.run( + [self.flutter_exe, "devices", "--no-version-check", "--suppress-analytics"], + cwd=os.getcwd(), + capture_output=True, + ) + if flutter_devices.returncode == 0 and flutter_devices.stdout: + console.log(flutter_devices.stdout, style=verbose2_style) diff --git a/sdk/python/packages/flet-cli/src/flet_cli/commands/flutter_base.py b/sdk/python/packages/flet-cli/src/flet_cli/commands/flutter_base.py index 982f7b396..32628672b 100644 --- a/sdk/python/packages/flet-cli/src/flet_cli/commands/flutter_base.py +++ b/sdk/python/packages/flet-cli/src/flet_cli/commands/flutter_base.py @@ -9,6 +9,7 @@ from packaging import version from rich.console import Console, Group from rich.panel import Panel +from rich.progress import Progress from rich.style import Style from rich.theme import Theme @@ -49,9 +50,12 @@ def __init__(self, parser: argparse.ArgumentParser) -> None: self.dart_exe = None self.flutter_exe = None self.verbose = False + self.package_platform = None + self.config_platform = None self.skip_flutter_doctor = get_bool_env_var("FLET_CLI_SKIP_FLUTTER_DOCTOR") self.no_rich_output = no_rich_output self.current_platform = platform.system() + self.progress = Progress(transient=True) def add_arguments(self, parser: argparse.ArgumentParser) -> None: parser.add_argument( @@ -69,10 +73,11 @@ def add_arguments(self, parser: argparse.ArgumentParser) -> None: def handle(self, options: argparse.Namespace) -> None: self.options = options + self.no_rich_output = self.no_rich_output or self.options.no_rich_output + self.verbose = self.options.verbose - def initialize_build(self): + def initialize_command(self): assert self.options - assert self.target_platform self.emojis = { "checkmark": "[green]OK[/]" if self.no_rich_output else "✅", "loading": "" if self.no_rich_output else "⏳", @@ -238,8 +243,7 @@ def run(self, args, cwd, env: Optional[dict] = None, capture_output=True): def cleanup(self, exit_code: int, message: Optional[str] = None): if exit_code == 0: - msg = message or f"Success! {self.emojis['success']}" - self.live.update(Panel(msg), refresh=True) + self.live.update(Panel(message) if message else "", refresh=True) else: msg = ( message From 1cc6ad9d15fe4f7176aad7b0c9f0b8e6e3266475 Mon Sep 17 00:00:00 2001 From: Feodor Fitsner Date: Wed, 26 Nov 2025 14:19:23 -0800 Subject: [PATCH 08/22] Refactor device listing and cleanup handling Improves device listing in the CLI by parsing and displaying connected devices in a formatted table, adds platform labels, and restricts device choices to iOS and Android. Refactors the cleanup method to accept richer message types (including Panel and Group) and removes its implementation from build_base.py, consolidating it in flutter_base.py. Updates usages in build.py and debug.py to use Panel for success messages. --- .../flet-cli/src/flet_cli/commands/build.py | 23 ++-- .../src/flet_cli/commands/build_base.py | 33 ------ .../flet-cli/src/flet_cli/commands/debug.py | 3 +- .../flet-cli/src/flet_cli/commands/devices.py | 101 +++++++++++++++++- .../src/flet_cli/commands/flutter_base.py | 9 +- 5 files changed, 118 insertions(+), 51 deletions(-) diff --git a/sdk/python/packages/flet-cli/src/flet_cli/commands/build.py b/sdk/python/packages/flet-cli/src/flet_cli/commands/build.py index cff84ce6b..ac7f5c916 100644 --- a/sdk/python/packages/flet-cli/src/flet_cli/commands/build.py +++ b/sdk/python/packages/flet-cli/src/flet_cli/commands/build.py @@ -2,6 +2,7 @@ from rich.console import Group from rich.live import Live +from rich.panel import Panel from flet_cli.commands.build_base import BaseBuildCommand, console @@ -59,16 +60,18 @@ def handle(self, options: argparse.Namespace) -> None: self.cleanup( 0, message=( - f"Successfully built your [cyan]" - f"{self.platforms[self.target_platform]['status_text']}" - f"[/cyan]! {self.emojis['success']} " - f"Find it in [cyan]{self.rel_out_dir}[/cyan] directory. " - f"{self.emojis['directory']}" - + ( - "\nRun [cyan]flet serve[/cyan] command to start a web server " - "with your app. " - if self.target_platform == "web" - else "" + Panel( + f"Successfully built your [cyan]" + f"{self.platforms[self.target_platform]['status_text']}" + f"[/cyan]! {self.emojis['success']} " + f"Find it in [cyan]{self.rel_out_dir}[/cyan] directory. " + f"{self.emojis['directory']}" + + ( + "\nRun [cyan]flet serve[/cyan] command to " + "start a web server with your app. " + if self.target_platform == "web" + else "" + ) ) ), ) diff --git a/sdk/python/packages/flet-cli/src/flet_cli/commands/build_base.py b/sdk/python/packages/flet-cli/src/flet_cli/commands/build_base.py index c5fd6ee44..24b9ee22c 100644 --- a/sdk/python/packages/flet-cli/src/flet_cli/commands/build_base.py +++ b/sdk/python/packages/flet-cli/src/flet_cli/commands/build_base.py @@ -3,14 +3,12 @@ import os import platform import shutil -import sys from pathlib import Path from typing import Optional, cast import yaml from packaging import version from packaging.requirements import Requirement -from rich.console import Group from rich.panel import Panel from rich.table import Column, Table @@ -1958,37 +1956,6 @@ def run(self, args, cwd, env: Optional[dict] = None, capture_output=True): log=self.log_stdout, ) - def cleanup(self, exit_code: int, message: Optional[str] = None): - if exit_code == 0: - msg = message or f"Success! {self.emojis['success']}" - self.live.update(Panel(msg), refresh=True) - else: - msg = ( - message - if message is not None - else "Error building Flet app - see the log of failed command above." - ) - - # windows has been reported to raise encoding errors - # when running `flutter doctor` - # so skip running `flutter doctor` if no_rich_output is True - # and platform is Windows - if not ( - (self.no_rich_output and self.current_platform == "Windows") - or self.skip_flutter_doctor - ): - status = console.status( - "[bold blue]Running Flutter doctor...", - spinner="bouncingBall", - ) - self.live.update( - Group(Panel(msg, style=error_style), status), refresh=True - ) - self.run_flutter_doctor() - self.live.update(Panel(msg, style=error_style), refresh=True) - - sys.exit(exit_code) - def run_flutter_doctor(self): flutter_doctor = self.run( [self.flutter_exe, "doctor", "--no-version-check", "--suppress-analytics"], diff --git a/sdk/python/packages/flet-cli/src/flet_cli/commands/debug.py b/sdk/python/packages/flet-cli/src/flet_cli/commands/debug.py index 60a0557fd..ceaaf3dd2 100644 --- a/sdk/python/packages/flet-cli/src/flet_cli/commands/debug.py +++ b/sdk/python/packages/flet-cli/src/flet_cli/commands/debug.py @@ -5,6 +5,7 @@ from rich.console import Group from rich.live import Live +from rich.panel import Panel from flet_cli.commands.build_base import BaseBuildCommand, console, verbose2_style @@ -103,7 +104,7 @@ def handle(self, options: argparse.Namespace) -> None: self.customize_icons() self.customize_splash_images() self.run_flutter() - self.cleanup(0, message=("Debug session ended.")) + self.cleanup(0, message=Panel("Debug session ended.")) def check_device_id(self): if self.device_id is None and self.debug_platform in [ diff --git a/sdk/python/packages/flet-cli/src/flet_cli/commands/devices.py b/sdk/python/packages/flet-cli/src/flet_cli/commands/devices.py index 8b0d84d72..74e5abce4 100644 --- a/sdk/python/packages/flet-cli/src/flet_cli/commands/devices.py +++ b/sdk/python/packages/flet-cli/src/flet_cli/commands/devices.py @@ -3,6 +3,8 @@ from rich.console import Group from rich.live import Live +from rich.panel import Panel +from rich.table import Column, Table from flet_cli.commands.build_base import BaseFlutterCommand, console, verbose2_style @@ -15,13 +17,17 @@ class Command(BaseFlutterCommand): def __init__(self, parser: argparse.ArgumentParser) -> None: super().__init__(parser) self.devices_platform = None + self.platform_labels = { + "ios": "iOS", + "android": "Android", + } def add_arguments(self, parser: argparse.ArgumentParser) -> None: parser.add_argument( "platform", type=str, nargs="?", - choices=["ios", "android", "web"], + choices=["ios", "android"], help="The target platform to list devices for. " "If not specified, lists all platforms.", ) @@ -70,7 +76,7 @@ def handle(self, options: argparse.Namespace) -> None: # self.live.update("", refresh=True) # return self.run_flutter_devices() - self.cleanup(0, message=("")) + self.cleanup(0) def initialize_command(self): self.package_platform = "" @@ -85,5 +91,92 @@ def run_flutter_devices(self): cwd=os.getcwd(), capture_output=True, ) - if flutter_devices.returncode == 0 and flutter_devices.stdout: - console.log(flutter_devices.stdout, style=verbose2_style) + output = flutter_devices.stdout or "" + + if flutter_devices.returncode != 0: + error_output = flutter_devices.stderr or output + self.cleanup( + flutter_devices.returncode, + message=( + error_output or "Failed to retrieve devices via `flutter devices`." + ), + ) + return None + + if output and self.verbose >= 1: + console.log(output, style=verbose2_style) + + footer = ( + '\nRun [green]"flet emulators"[/green] to list ' + "and start any available device emulators.\n" + ) + + devices = [ + device + for device in self._parse_devices_output(output) + if device["platform"] in ("ios", "android") + ] + if self.devices_platform: + devices = [d for d in devices if d["platform"] == self.devices_platform] + + if not devices: + platform_label = self.platform_labels.get( + self.devices_platform, "iOS/Android" + ) + self.cleanup(0, Group(Panel(f"No {platform_label} devices found."), footer)) + + devices_table = Table( + Column("Device", style="cyan", justify="left"), + Column("ID", style="magenta", justify="left", overflow="fold"), + Column("Platform", style="green", justify="center"), + Column("Details", style="yellow", justify="left"), + title="Connected Devices", + header_style="bold", + show_lines=True, + ) + + for device in devices: + devices_table.add_row( + device["name"], + device["id"], + device["platform_label"], + device["details"], + ) + + self.cleanup(0, message=Group(devices_table, footer)) + + def _parse_devices_output(self, output: str) -> list[dict]: + devices = [] + for raw_line in output.splitlines(): + line = raw_line.strip() + if not line or "\u2022" not in line: + continue + parts = [p.strip() for p in line.split("\u2022")] + if len(parts) < 3: + continue + + name = parts[0] + device_id = parts[1] + platform_raw = parts[2] + details = parts[3] if len(parts) > 3 else "" + platform = self._normalize_platform(platform_raw) + + devices.append( + { + "name": name, + "id": device_id, + "platform": platform, + "platform_label": platform_raw, + "details": details, + } + ) + + return devices + + def _normalize_platform(self, platform_raw: str) -> str | None: + platform_lower = platform_raw.lower() + if "android" in platform_lower: + return "android" + if "ios" in platform_lower: + return "ios" + return None diff --git a/sdk/python/packages/flet-cli/src/flet_cli/commands/flutter_base.py b/sdk/python/packages/flet-cli/src/flet_cli/commands/flutter_base.py index 32628672b..c76731c45 100644 --- a/sdk/python/packages/flet-cli/src/flet_cli/commands/flutter_base.py +++ b/sdk/python/packages/flet-cli/src/flet_cli/commands/flutter_base.py @@ -4,7 +4,7 @@ import re import shutil import sys -from typing import Optional +from typing import Any, Optional from packaging import version from rich.console import Console, Group @@ -241,9 +241,12 @@ def run(self, args, cwd, env: Optional[dict] = None, capture_output=True): log=self.log_stdout, ) - def cleanup(self, exit_code: int, message: Optional[str] = None): + def cleanup(self, exit_code: int, message: Any = None): if exit_code == 0: - self.live.update(Panel(message) if message else "", refresh=True) + self.live.update( + message if message else "", + refresh=True, + ) else: msg = ( message From 875a56fd755f55cd1a560bdb6be41977f20b9be5 Mon Sep 17 00:00:00 2001 From: Feodor Fitsner Date: Wed, 26 Nov 2025 14:29:28 -0800 Subject: [PATCH 09/22] Add device timeout and connection filters to CLI Introduces --device-timeout and --device-connection arguments to filter devices by connection type and set timeout for device detection. Updates device table columns and parsing logic to support these new options. --- .../flet-cli/src/flet_cli/commands/devices.py | 75 +++++++++++-------- 1 file changed, 44 insertions(+), 31 deletions(-) diff --git a/sdk/python/packages/flet-cli/src/flet_cli/commands/devices.py b/sdk/python/packages/flet-cli/src/flet_cli/commands/devices.py index 74e5abce4..a11d6334a 100644 --- a/sdk/python/packages/flet-cli/src/flet_cli/commands/devices.py +++ b/sdk/python/packages/flet-cli/src/flet_cli/commands/devices.py @@ -17,6 +17,8 @@ class Command(BaseFlutterCommand): def __init__(self, parser: argparse.ArgumentParser) -> None: super().__init__(parser) self.devices_platform = None + self.device_timeout = 10 + self.device_connection = "default" self.platform_labels = { "ios": "iOS", "android": "Android", @@ -31,39 +33,31 @@ def add_arguments(self, parser: argparse.ArgumentParser) -> None: help="The target platform to list devices for. " "If not specified, lists all platforms.", ) - # parser.add_argument( - # "--device-id", - # "-d", - # type=str, - # dest="device_id", - # help="Device ID to run the app on for iOS and Android builds.", - # ) - # parser.add_argument( - # "--show-devices", - # dest="show_devices", - # action="store_true", - # default=False, - # help="Show connected devices for iOS and Android builds.", - # ) - # parser.add_argument( - # "--release", - # dest="release", - # action="store_true", - # default=False, - # help="Build the app in release mode.", - # ) - # parser.add_argument( - # "--route", - # type=str, - # dest="route", - # help="Route to open the app on for web, iOS and Android builds.", - # ) + parser.add_argument( + "--device-timeout", + type=int, + default=10, + dest="device_timeout", + help="Time (in seconds) to wait for devices to attach (default: 10).", + ) + parser.add_argument( + "--device-connection", + choices=["both", "attached", "wireless"], + default="both", + dest="device_connection", + help="Filter devices by connection type: attached (USB) or wireless " + "(default: both).", + ) super().add_arguments(parser) def handle(self, options: argparse.Namespace) -> None: super().handle(options) if self.options and "platform" in self.options and self.options.platform: self.devices_platform = self.options.platform + if self.options and "device_timeout" in self.options: + self.device_timeout = self.options.device_timeout or 10 + if self.options and "device_connection" in self.options: + self.device_connection = self.options.device_connection self.status = console.status( "[bold blue]Initializing environment...", @@ -87,7 +81,14 @@ def initialize_command(self): def run_flutter_devices(self): self.update_status("[bold blue]Checking connected devices...") flutter_devices = self.run( - [self.flutter_exe, "devices", "--no-version-check", "--suppress-analytics"], + [ + self.flutter_exe, + "devices", + "--no-version-check", + "--suppress-analytics", + "--device-timeout", + str(self.device_timeout), + ], cwd=os.getcwd(), capture_output=True, ) @@ -116,6 +117,8 @@ def run_flutter_devices(self): for device in self._parse_devices_output(output) if device["platform"] in ("ios", "android") ] + if self.device_connection != "both": + devices = [d for d in devices if d["connection"] == self.device_connection] if self.devices_platform: devices = [d for d in devices if d["platform"] == self.devices_platform] @@ -126,8 +129,8 @@ def run_flutter_devices(self): self.cleanup(0, Group(Panel(f"No {platform_label} devices found."), footer)) devices_table = Table( - Column("Device", style="cyan", justify="left"), - Column("ID", style="magenta", justify="left", overflow="fold"), + Column("ID", style="magenta", justify="left", no_wrap=True), + Column("Name", style="cyan", justify="left"), Column("Platform", style="green", justify="center"), Column("Details", style="yellow", justify="left"), title="Connected Devices", @@ -137,8 +140,8 @@ def run_flutter_devices(self): for device in devices: devices_table.add_row( - device["name"], device["id"], + device["name"], device["platform_label"], device["details"], ) @@ -160,6 +163,7 @@ def _parse_devices_output(self, output: str) -> list[dict]: platform_raw = parts[2] details = parts[3] if len(parts) > 3 else "" platform = self._normalize_platform(platform_raw) + connection = self._detect_connection_type(parts) devices.append( { @@ -167,6 +171,7 @@ def _parse_devices_output(self, output: str) -> list[dict]: "id": device_id, "platform": platform, "platform_label": platform_raw, + "connection": connection, "details": details, } ) @@ -180,3 +185,11 @@ def _normalize_platform(self, platform_raw: str) -> str | None: if "ios" in platform_lower: return "ios" return None + + def _detect_connection_type(self, parts: list[str]) -> str: + # Heuristic: Flutter prints "wireless" or "wifi" in one of the bullet segments + # for wireless devices. Default to "attached" otherwise. + parts_lower = " ".join(parts).lower() + if "wireless" in parts_lower or "wi-fi" in parts_lower or "wifi" in parts_lower: + return "wireless" + return "attached" From b1315e94aafa2673354b861ec87eb5925939c308 Mon Sep 17 00:00:00 2001 From: Feodor Fitsner Date: Wed, 26 Nov 2025 18:20:19 -0800 Subject: [PATCH 10/22] Improve device platform labeling and verbose logging Added unified platform labeling for devices and updated status messages in devices.py to reflect the selected platform. Console log messages for Flutter, JDK, and Android SDK installation in flutter_base.py are now shown only in verbose mode for cleaner output. --- .../flet-cli/src/flet_cli/commands/devices.py | 13 ++++++++----- .../src/flet_cli/commands/flutter_base.py | 15 ++++++++++----- 2 files changed, 18 insertions(+), 10 deletions(-) diff --git a/sdk/python/packages/flet-cli/src/flet_cli/commands/devices.py b/sdk/python/packages/flet-cli/src/flet_cli/commands/devices.py index a11d6334a..8a4eb4b3d 100644 --- a/sdk/python/packages/flet-cli/src/flet_cli/commands/devices.py +++ b/sdk/python/packages/flet-cli/src/flet_cli/commands/devices.py @@ -22,6 +22,7 @@ def __init__(self, parser: argparse.ArgumentParser) -> None: self.platform_labels = { "ios": "iOS", "android": "Android", + None: "iOS/Android", } def add_arguments(self, parser: argparse.ArgumentParser) -> None: @@ -58,6 +59,7 @@ def handle(self, options: argparse.Namespace) -> None: self.device_timeout = self.options.device_timeout or 10 if self.options and "device_connection" in self.options: self.device_connection = self.options.device_connection + self.platform_label = self.platform_labels[self.devices_platform] self.status = console.status( "[bold blue]Initializing environment...", @@ -79,7 +81,9 @@ def initialize_command(self): super().initialize_command() def run_flutter_devices(self): - self.update_status("[bold blue]Checking connected devices...") + self.update_status( + f"[bold blue]Checking connected {self.platform_label} devices..." + ) flutter_devices = self.run( [ self.flutter_exe, @@ -123,17 +127,16 @@ def run_flutter_devices(self): devices = [d for d in devices if d["platform"] == self.devices_platform] if not devices: - platform_label = self.platform_labels.get( - self.devices_platform, "iOS/Android" + self.cleanup( + 0, Group(Panel(f"No {self.platform_label} devices found."), footer) ) - self.cleanup(0, Group(Panel(f"No {platform_label} devices found."), footer)) devices_table = Table( Column("ID", style="magenta", justify="left", no_wrap=True), Column("Name", style="cyan", justify="left"), Column("Platform", style="green", justify="center"), Column("Details", style="yellow", justify="left"), - title="Connected Devices", + title=f"Connected {self.platform_label} devices", header_style="bold", show_lines=True, ) diff --git a/sdk/python/packages/flet-cli/src/flet_cli/commands/flutter_base.py b/sdk/python/packages/flet-cli/src/flet_cli/commands/flutter_base.py index c76731c45..eba835ad4 100644 --- a/sdk/python/packages/flet-cli/src/flet_cli/commands/flutter_base.py +++ b/sdk/python/packages/flet-cli/src/flet_cli/commands/flutter_base.py @@ -175,9 +175,11 @@ def install_flutter(self): console.log(config_result.stderr, style=error_style) self.cleanup(config_result.returncode) - console.log( - f"Flutter {MINIMAL_FLUTTER_VERSION} installed {self.emojis['checkmark']}" - ) + if self.verbose > 0: + console.log( + f"Flutter {MINIMAL_FLUTTER_VERSION} installed " + f"{self.emojis['checkmark']}" + ) def install_jdk(self): from flet_cli.utils.jdk import install_jdk @@ -210,7 +212,8 @@ def install_jdk(self): console.log(config_result.stderr, style=error_style) self.cleanup(config_result.returncode) - console.log(f"JDK installed {self.emojis['checkmark']}") + if self.verbose > 0: + console.log(f"JDK installed {self.emojis['checkmark']}") def install_android_sdk(self): from flet_cli.utils.android_sdk import AndroidSDK @@ -219,7 +222,9 @@ def install_android_sdk(self): self.env["ANDROID_HOME"] = AndroidSDK( self.env["JAVA_HOME"], self.log_stdout, progress=self.progress ).install() - console.log(f"Android SDK installed {self.emojis['checkmark']}") + + if self.verbose > 0: + console.log(f"Android SDK installed {self.emojis['checkmark']}") def find_flutter_batch(self, exe_filename: str): batch_path = shutil.which(exe_filename) From 535fc3f7ff54f144c105c524a1eebe0d5692cfa7 Mon Sep 17 00:00:00 2001 From: Feodor Fitsner Date: Thu, 27 Nov 2025 11:36:26 -0800 Subject: [PATCH 11/22] Add emulators command and improve argument parsing Introduces a new 'emulators' command for listing, creating, and launching emulators. Refactors argument parsing for several commands to use 'type=str.lower' for case-insensitive choices. Adds platform label mapping to BaseFlutterCommand and improves device and debug command output formatting. --- .../packages/flet-cli/src/flet_cli/cli.py | 4 +- .../flet-cli/src/flet_cli/commands/build.py | 2 +- .../src/flet_cli/commands/build_base.py | 3 + .../flet-cli/src/flet_cli/commands/create.py | 3 +- .../flet-cli/src/flet_cli/commands/debug.py | 7 +- .../flet-cli/src/flet_cli/commands/devices.py | 10 +- .../src/flet_cli/commands/emulators.py | 282 ++++++++++++++++++ .../src/flet_cli/commands/flutter_base.py | 9 + .../flet-cli/src/flet_cli/commands/publish.py | 2 + 9 files changed, 310 insertions(+), 12 deletions(-) create mode 100644 sdk/python/packages/flet-cli/src/flet_cli/commands/emulators.py diff --git a/sdk/python/packages/flet-cli/src/flet_cli/cli.py b/sdk/python/packages/flet-cli/src/flet_cli/cli.py index 00eb3d662..463997d29 100644 --- a/sdk/python/packages/flet-cli/src/flet_cli/cli.py +++ b/sdk/python/packages/flet-cli/src/flet_cli/cli.py @@ -6,7 +6,8 @@ import flet_cli.commands.create import flet_cli.commands.debug import flet_cli.commands.devices -import flet_cli.commands.doctor # Adding the doctor command +import flet_cli.commands.doctor +import flet_cli.commands.emulators import flet_cli.commands.pack import flet_cli.commands.publish import flet_cli.commands.run @@ -84,6 +85,7 @@ def get_parser() -> argparse.ArgumentParser: flet_cli.commands.pack.Command.register_to(sp, "pack") flet_cli.commands.publish.Command.register_to(sp, "publish") flet_cli.commands.serve.Command.register_to(sp, "serve") + flet_cli.commands.emulators.Command.register_to(sp, "emulators") flet_cli.commands.devices.Command.register_to(sp, "devices") flet_cli.commands.doctor.Command.register_to(sp, "doctor") diff --git a/sdk/python/packages/flet-cli/src/flet_cli/commands/build.py b/sdk/python/packages/flet-cli/src/flet_cli/commands/build.py index ac7f5c916..a67b1eabc 100644 --- a/sdk/python/packages/flet-cli/src/flet_cli/commands/build.py +++ b/sdk/python/packages/flet-cli/src/flet_cli/commands/build.py @@ -21,7 +21,7 @@ def __init__(self, parser: argparse.ArgumentParser) -> None: def add_arguments(self, parser: argparse.ArgumentParser) -> None: parser.add_argument( "target_platform", - type=str, + type=str.lower, choices=["macos", "linux", "windows", "web", "apk", "aab", "ipa"], help="The target platform or type of package to build", ) diff --git a/sdk/python/packages/flet-cli/src/flet_cli/commands/build_base.py b/sdk/python/packages/flet-cli/src/flet_cli/commands/build_base.py index 24b9ee22c..acff1ad83 100644 --- a/sdk/python/packages/flet-cli/src/flet_cli/commands/build_base.py +++ b/sdk/python/packages/flet-cli/src/flet_cli/commands/build_base.py @@ -361,12 +361,14 @@ def add_arguments(self, parser: argparse.ArgumentParser) -> None: parser.add_argument( "--web-renderer", dest="web_renderer", + type=str.lower, choices=["auto", "canvaskit", "skwasm"], help="Flutter web renderer to use (web only)", ) parser.add_argument( "--route-url-strategy", dest="route_url_strategy", + type=str.lower, choices=["path", "hash"], help="Base URL path to serve the app from. " "Useful if the app is hosted in a subdirectory (web only)", @@ -503,6 +505,7 @@ def add_arguments(self, parser: argparse.ArgumentParser) -> None: parser.add_argument( "--permissions", dest="permissions", + type=str.lower, nargs="+", default=[], choices=["location", "camera", "microphone", "photo_library"], diff --git a/sdk/python/packages/flet-cli/src/flet_cli/commands/create.py b/sdk/python/packages/flet-cli/src/flet_cli/commands/create.py index 8f7d6b926..c70760e17 100644 --- a/sdk/python/packages/flet-cli/src/flet_cli/commands/create.py +++ b/sdk/python/packages/flet-cli/src/flet_cli/commands/create.py @@ -47,6 +47,7 @@ def add_arguments(self, parser: argparse.ArgumentParser) -> None: parser.add_argument( "--template", dest="template", + type=str.lower, choices=["app", "extension"], default="app", required=False, @@ -111,7 +112,7 @@ def handle(self, options: argparse.Namespace) -> None: if self.verbose > 0: console.print(f"[cyan]Files created at[/cyan] {out_dir}:\n") - for root, dirs, files in os.walk(out_dir): + for root, _, files in os.walk(out_dir): for file in files: rel_path = os.path.relpath(os.path.join(root, file), out_dir) console.print(rel_path) diff --git a/sdk/python/packages/flet-cli/src/flet_cli/commands/debug.py b/sdk/python/packages/flet-cli/src/flet_cli/commands/debug.py index ceaaf3dd2..fdd6b73a8 100644 --- a/sdk/python/packages/flet-cli/src/flet_cli/commands/debug.py +++ b/sdk/python/packages/flet-cli/src/flet_cli/commands/debug.py @@ -31,7 +31,7 @@ def __init__(self, parser: argparse.ArgumentParser) -> None: def add_arguments(self, parser: argparse.ArgumentParser) -> None: parser.add_argument( "platform", - type=str, + type=str.lower, nargs="?", choices=["macos", "linux", "windows", "web", "ios", "android"], help="The target platform to run the app on", @@ -75,6 +75,7 @@ def handle(self, options: argparse.Namespace) -> None: self.debug_platform = platform.system().lower() if self.debug_platform == "darwin": self.debug_platform = "macos" + self.platform_label = self.platform_labels[self.debug_platform] self.target_platform = self.debug_platforms[self.debug_platform][ "target_platform" ] @@ -136,8 +137,10 @@ def add_flutter_command_args(self, args: list[str]): def run_flutter(self): assert self.platforms assert self.target_platform + mode = "release" if self.options.release else "debug" self.update_status( - f"[bold blue]Running the app on [cyan]{self.debug_platform}[/cyan]..." + f"[bold blue]Running {mode} version of the app on " + f"[cyan]{self.platform_label}[/cyan] device..." ) with contextlib.suppress(KeyboardInterrupt): diff --git a/sdk/python/packages/flet-cli/src/flet_cli/commands/devices.py b/sdk/python/packages/flet-cli/src/flet_cli/commands/devices.py index 8a4eb4b3d..1dd1ef213 100644 --- a/sdk/python/packages/flet-cli/src/flet_cli/commands/devices.py +++ b/sdk/python/packages/flet-cli/src/flet_cli/commands/devices.py @@ -11,7 +11,7 @@ class Command(BaseFlutterCommand): """ - List all connected devices. + List all connected iOS and Android devices. """ def __init__(self, parser: argparse.ArgumentParser) -> None: @@ -19,16 +19,11 @@ def __init__(self, parser: argparse.ArgumentParser) -> None: self.devices_platform = None self.device_timeout = 10 self.device_connection = "default" - self.platform_labels = { - "ios": "iOS", - "android": "Android", - None: "iOS/Android", - } def add_arguments(self, parser: argparse.ArgumentParser) -> None: parser.add_argument( "platform", - type=str, + type=str.lower, nargs="?", choices=["ios", "android"], help="The target platform to list devices for. " @@ -43,6 +38,7 @@ def add_arguments(self, parser: argparse.ArgumentParser) -> None: ) parser.add_argument( "--device-connection", + type=str.lower, choices=["both", "attached", "wireless"], default="both", dest="device_connection", diff --git a/sdk/python/packages/flet-cli/src/flet_cli/commands/emulators.py b/sdk/python/packages/flet-cli/src/flet_cli/commands/emulators.py new file mode 100644 index 000000000..141331f46 --- /dev/null +++ b/sdk/python/packages/flet-cli/src/flet_cli/commands/emulators.py @@ -0,0 +1,282 @@ +import argparse +import os + +from rich.console import Group +from rich.live import Live +from rich.panel import Panel +from rich.table import Column, Table + +from flet_cli.commands.build_base import BaseFlutterCommand, console, verbose2_style + + +class Command(BaseFlutterCommand): + """ + List, create, and launch available emulators. + """ + + def __init__(self, parser: argparse.ArgumentParser) -> None: + super().__init__(parser) + self.launch_target = None + self.cold_boot = False + self.create_emulator = False + self.emulator_name = None + + def add_arguments(self, parser: argparse.ArgumentParser) -> None: + parser.add_argument( + "--launch", + dest="launch", + type=str, + help="Launch a specific emulator by ID or name.", + ) + parser.add_argument( + "--cold", + dest="cold", + action="store_true", + default=False, + help="Cold boot the emulator when launching.", + ) + parser.add_argument( + "--create", + dest="create", + action="store_true", + default=False, + help="Create a new emulator using Flutter defaults.", + ) + parser.add_argument( + "--name", + dest="name", + type=str, + help="Name for the new emulator (used with --create).", + ) + super().add_arguments(parser) + + def handle(self, options: argparse.Namespace) -> None: + super().handle(options) + if self.options: + self.launch_target = self.options.launch + self.cold_boot = bool(self.options.cold) + self.create_emulator = bool(self.options.create) + self.emulator_name = self.options.name + + if self.create_emulator and self.launch_target: + console.log( + "Please choose either --create or --launch, not both.", + style=verbose2_style, + ) + self.cleanup( + 1, Panel("Please choose either --create or --launch, not both.") + ) + + self.status = console.status( + "[bold blue]Initializing environment...", + spinner="bouncingBall", + ) + with Live(Group(self.status, self.progress), console=console) as self.live: + self.initialize_command() + if self.create_emulator: + self._create_emulator() + return + if self.launch_target: + self._launch_emulator() + return + self._list_emulators() + + def initialize_command(self): + self.package_platform = "" + self.config_platform = "" + + super().initialize_command() + + def _list_emulators(self): + self.update_status("[bold blue]Listing available emulators...") + emulators_process = self.run( + [ + self.flutter_exe, + "emulators", + "--no-version-check", + "--suppress-analytics", + ], + cwd=os.getcwd(), + capture_output=True, + ) + output = emulators_process.stdout or "" + + if emulators_process.returncode != 0: + error_output = emulators_process.stderr or output + self.cleanup( + emulators_process.returncode, + message=( + error_output + or "Failed to retrieve emulators via `flutter emulators`." + ), + ) + return + + if output and self.verbose >= 1: + console.log(output, style=verbose2_style) + + emulators = self._parse_emulators_output(output) + if not emulators: + footer = ( + '\nRun [green]"flet emulators --create"[/green] ' + "to create a new emulator.\n" + ) + self.cleanup(0, Group(Panel("No emulators found."), footer)) + + table = Table( + Column("Name", style="cyan", justify="left"), + Column("ID", style="magenta", justify="left", no_wrap=True), + Column("Platform", style="green", justify="center"), + Column("Manufacturer", style="yellow", justify="left"), + title="Available emulators", + header_style="bold", + show_lines=True, + ) + + for emulator in emulators: + table.add_row( + emulator["name"], + emulator["id"], + emulator["platform_label"], + emulator["manufacturer"], + ) + + footer = ( + "\n" + "Launch an emulator with " + '[green]"flet emulators --launch "[/green].\n' + "Create a new emulator with " + '[green]"flet emulators --create [--name ]"[/green].\n' + "\n" + "You can find more information on managing emulators at the links below:\n" + " https://developer.android.com/studio/run/managing-avds\n" + " https://developer.android.com/studio/command-line/avdmanager" + ) + + self.cleanup(0, message=Group(table, footer)) + + def _launch_emulator(self): + assert self.launch_target + self.update_status( + f"[bold blue]Launching emulator [cyan]{self.launch_target}[/cyan]..." + ) + args = [ + self.flutter_exe, + "emulators", + "--launch", + self.launch_target, + "--no-version-check", + "--suppress-analytics", + ] + if self.cold_boot: + args.append("--cold") + + launch_result = self.run( + args, + cwd=os.getcwd(), + capture_output=True, + ) + + output = launch_result.stdout or "" + if launch_result.returncode != 0: + error_output = launch_result.stderr or output + self.cleanup( + launch_result.returncode, + Panel( + error_output or f"Failed to launch emulator '{self.launch_target}'." + ), + ) + return + + if output and self.verbose >= 1: + console.log(output, style=verbose2_style) + + mode = " (cold boot)" if self.cold_boot else "" + self.cleanup( + 0, + Panel( + f"Emulator [cyan]{self.launch_target}[/cyan] launched{mode}.", + ), + ) + + def _create_emulator(self): + self.update_status("[bold blue]Creating emulator...") + args = [ + self.flutter_exe, + "emulators", + "--create", + "--no-version-check", + "--suppress-analytics", + ] + if self.emulator_name: + args.extend(["--name", self.emulator_name]) + + create_result = self.run( + args, + cwd=os.getcwd(), + capture_output=True, + ) + output = create_result.stdout or "" + if create_result.returncode != 0: + error_output = create_result.stderr or output + self.cleanup( + create_result.returncode, + Panel(error_output or "Failed to create emulator."), + ) + return + + if output and self.verbose >= 1: + console.log(output, style=verbose2_style) + + created_name = self.emulator_name or "emulator" + self.cleanup( + 0, + Panel( + f"Created emulator [cyan]{created_name}[/cyan]. " + "Use `flet emulators` to list it or " + f"`flet emulators --launch {created_name}` to start it.", + ), + ) + + def _parse_emulators_output(self, output: str) -> list[dict]: + emulators = [] + for raw_line in output.splitlines(): + line = raw_line.strip() + if not line or "\u2022" not in line: + continue + parts = [p.strip() for p in line.split("\u2022") if p.strip()] + if len(parts) < 2: + continue + + name = parts[0] + emulator_id = parts[1] + # Skip header rows printed by `flutter emulators` (Id • Name • Platform ...) + if name.lower() == "id" and emulator_id.lower() == "name": + continue + details_segments = parts[2:] + platform_raw = details_segments[-1] if details_segments else "" + platform = self._normalize_platform(platform_raw) + manufacturer = ( + " • ".join(details_segments[:-1]) if len(details_segments) > 1 else "" + ) + platform_label = platform_raw or platform or "Unknown" + + emulators.append( + { + "name": name, + "id": emulator_id, + "platform": platform, + "platform_label": platform_label, + "manufacturer": manufacturer, + } + ) + + return emulators + + def _normalize_platform(self, platform_raw: str) -> str | None: + platform_lower = platform_raw.lower() + if "android" in platform_lower: + return "android" + if "ios" in platform_lower: + return "ios" + return None diff --git a/sdk/python/packages/flet-cli/src/flet_cli/commands/flutter_base.py b/sdk/python/packages/flet-cli/src/flet_cli/commands/flutter_base.py index eba835ad4..e7d583eb2 100644 --- a/sdk/python/packages/flet-cli/src/flet_cli/commands/flutter_base.py +++ b/sdk/python/packages/flet-cli/src/flet_cli/commands/flutter_base.py @@ -56,6 +56,15 @@ def __init__(self, parser: argparse.ArgumentParser) -> None: self.no_rich_output = no_rich_output self.current_platform = platform.system() self.progress = Progress(transient=True) + self.platform_labels = { + "windows": "Windows", + "macos": "macOS", + "linux": "Linux", + "web": "Web", + "ios": "iOS", + "android": "Android", + None: "iOS/Android", + } def add_arguments(self, parser: argparse.ArgumentParser) -> None: parser.add_argument( diff --git a/sdk/python/packages/flet-cli/src/flet_cli/commands/publish.py b/sdk/python/packages/flet-cli/src/flet_cli/commands/publish.py index b28549a52..fd9f05e38 100644 --- a/sdk/python/packages/flet-cli/src/flet_cli/commands/publish.py +++ b/sdk/python/packages/flet-cli/src/flet_cli/commands/publish.py @@ -88,6 +88,7 @@ def add_arguments(self, parser: argparse.ArgumentParser) -> None: parser.add_argument( "--web-renderer", dest="web_renderer", + type=str.lower, choices=["auto", "canvaskit", "skwasm"], default="auto", help="Flutter web renderer to use", @@ -95,6 +96,7 @@ def add_arguments(self, parser: argparse.ArgumentParser) -> None: parser.add_argument( "--route-url-strategy", dest="route_url_strategy", + type=str.lower, choices=["path", "hash"], default="path", help="Controls how routes are handled in the browser", From 93d8d8b522928a69bdc0ca48690a99b69ac36e60 Mon Sep 17 00:00:00 2001 From: Feodor Fitsner Date: Thu, 27 Nov 2025 11:56:50 -0800 Subject: [PATCH 12/22] Update build_base.py --- .../packages/flet-cli/src/flet_cli/commands/build_base.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/sdk/python/packages/flet-cli/src/flet_cli/commands/build_base.py b/sdk/python/packages/flet-cli/src/flet_cli/commands/build_base.py index acff1ad83..fc32eedd9 100644 --- a/sdk/python/packages/flet-cli/src/flet_cli/commands/build_base.py +++ b/sdk/python/packages/flet-cli/src/flet_cli/commands/build_base.py @@ -1044,7 +1044,7 @@ def create_flutter_project(self, second_pass=False): if not second_pass: self.flutter_dir.mkdir(parents=True, exist_ok=True) self.update_status( - "[bold blue]Creating Flutter bootstrap project from " + "[bold blue]Creating app shell from " f'{template_url} with ref "{template_ref}"...' ) @@ -1077,10 +1077,7 @@ def create_flutter_project(self, second_pass=False): shutil.copyfile(self.pubspec_path, f"{self.pubspec_path}.orig") if not second_pass: - console.log( - f"Created Flutter bootstrap project from {template_url} " - f'with ref "{template_ref}" {self.emojis["checkmark"]}' - ) + console.log(f"Created app shell {self.emojis['checkmark']}") hash.commit() From cb47dd8536cdfc7f40a2e5e159ea888ad130d992 Mon Sep 17 00:00:00 2001 From: Feodor Fitsner Date: Thu, 27 Nov 2025 13:13:22 -0800 Subject: [PATCH 13/22] Refactor platform handling in CLI commands Replaces 'package_platform' and 'config_platform' with 'require_android_sdk' for device and emulator commands, simplifying platform checks. Updates desktop support logic to use system platform detection instead of config_platform. This improves clarity and maintainability of platform-specific initialization. --- .../flet-cli/src/flet_cli/commands/build_base.py | 3 +++ .../flet-cli/src/flet_cli/commands/devices.py | 3 +-- .../flet-cli/src/flet_cli/commands/emulators.py | 3 +-- .../flet-cli/src/flet_cli/commands/flutter_base.py | 12 +++++++----- 4 files changed, 12 insertions(+), 9 deletions(-) diff --git a/sdk/python/packages/flet-cli/src/flet_cli/commands/build_base.py b/sdk/python/packages/flet-cli/src/flet_cli/commands/build_base.py index fc32eedd9..1286f5557 100644 --- a/sdk/python/packages/flet-cli/src/flet_cli/commands/build_base.py +++ b/sdk/python/packages/flet-cli/src/flet_cli/commands/build_base.py @@ -48,6 +48,8 @@ def __init__(self, parser: argparse.ArgumentParser) -> None: self.rel_out_dir = None self.assets_path = None self.target_platform = None + self.package_platform = None + self.config_platform = None self.flutter_dependencies = {} self.package_app_path = None self.template_data = None @@ -600,6 +602,7 @@ def initialize_command(self): self.package_platform = self.platforms[self.target_platform]["package_platform"] self.config_platform = self.platforms[self.target_platform]["config_platform"] + self.require_android_sdk = self.package_platform == "Android" super().initialize_command() diff --git a/sdk/python/packages/flet-cli/src/flet_cli/commands/devices.py b/sdk/python/packages/flet-cli/src/flet_cli/commands/devices.py index 1dd1ef213..68b8d40be 100644 --- a/sdk/python/packages/flet-cli/src/flet_cli/commands/devices.py +++ b/sdk/python/packages/flet-cli/src/flet_cli/commands/devices.py @@ -71,8 +71,7 @@ def handle(self, options: argparse.Namespace) -> None: self.cleanup(0) def initialize_command(self): - self.package_platform = "" - self.config_platform = "" + self.require_android_sdk = True super().initialize_command() diff --git a/sdk/python/packages/flet-cli/src/flet_cli/commands/emulators.py b/sdk/python/packages/flet-cli/src/flet_cli/commands/emulators.py index 141331f46..7eab8f126 100644 --- a/sdk/python/packages/flet-cli/src/flet_cli/commands/emulators.py +++ b/sdk/python/packages/flet-cli/src/flet_cli/commands/emulators.py @@ -82,8 +82,7 @@ def handle(self, options: argparse.Namespace) -> None: self._list_emulators() def initialize_command(self): - self.package_platform = "" - self.config_platform = "" + self.require_android_sdk = True super().initialize_command() diff --git a/sdk/python/packages/flet-cli/src/flet_cli/commands/flutter_base.py b/sdk/python/packages/flet-cli/src/flet_cli/commands/flutter_base.py index e7d583eb2..10b9bd75f 100644 --- a/sdk/python/packages/flet-cli/src/flet_cli/commands/flutter_base.py +++ b/sdk/python/packages/flet-cli/src/flet_cli/commands/flutter_base.py @@ -50,8 +50,7 @@ def __init__(self, parser: argparse.ArgumentParser) -> None: self.dart_exe = None self.flutter_exe = None self.verbose = False - self.package_platform = None - self.config_platform = None + self.require_android_sdk = False self.skip_flutter_doctor = get_bool_env_var("FLET_CLI_SKIP_FLUTTER_DOCTOR") self.no_rich_output = no_rich_output self.current_platform = platform.system() @@ -113,7 +112,7 @@ def initialize_command(self): console.log("Flutter executable:", self.flutter_exe, style=verbose2_style) console.log("Dart executable:", self.dart_exe, style=verbose2_style) - if self.package_platform == "Android": + if self.require_android_sdk: self.install_jdk() self.install_android_sdk() @@ -160,7 +159,10 @@ def install_flutter(self): self.env["PATH"] = os.pathsep.join([os.path.join(flutter_dir, "bin"), path_env]) # desktop mode - if self.config_platform in ["macos", "windows", "linux"]: + desktop_platform = platform.system().lower() + if desktop_platform == "darwin": + desktop_platform = "macos" + if desktop_platform in ["macos", "windows", "linux"]: if self.verbose > 0: console.log( "Ensure Flutter has desktop support enabled", @@ -172,7 +174,7 @@ def install_flutter(self): "config", "--no-version-check", "--suppress-analytics", - f"--enable-{self.config_platform}-desktop", + f"--enable-{desktop_platform}-desktop", ], cwd=os.getcwd(), capture_output=self.verbose < 1, From 02877c5a8971b122b9cadafc54321511e8564ed5 Mon Sep 17 00:00:00 2001 From: Feodor Fitsner Date: Thu, 27 Nov 2025 13:51:41 -0800 Subject: [PATCH 14/22] Add confirmation prompt for Android SDK installation Introduces a '--yes' CLI option to automatically confirm dependency installations and adds a user prompt before installing the Android SDK if minimal packages are not present. Refactors AndroidSDK to include a static method for checking minimal package installation. --- .../src/flet_cli/commands/flutter_base.py | 48 +++++++++++++++++++ .../src/flet_cli/utils/android_sdk.py | 16 +++++++ 2 files changed, 64 insertions(+) diff --git a/sdk/python/packages/flet-cli/src/flet_cli/commands/flutter_base.py b/sdk/python/packages/flet-cli/src/flet_cli/commands/flutter_base.py index 10b9bd75f..70808328f 100644 --- a/sdk/python/packages/flet-cli/src/flet_cli/commands/flutter_base.py +++ b/sdk/python/packages/flet-cli/src/flet_cli/commands/flutter_base.py @@ -10,6 +10,7 @@ from rich.console import Console, Group from rich.panel import Panel from rich.progress import Progress +from rich.prompt import Confirm from rich.style import Style from rich.theme import Theme @@ -64,6 +65,8 @@ def __init__(self, parser: argparse.ArgumentParser) -> None: "android": "Android", None: "iOS/Android", } + self.assume_yes = False + self._android_install_confirmed = False def add_arguments(self, parser: argparse.ArgumentParser) -> None: parser.add_argument( @@ -72,6 +75,14 @@ def add_arguments(self, parser: argparse.ArgumentParser) -> None: default=False, help="Disable rich output and prefer plain text. Useful on Windows builds", ) + parser.add_argument( + "--yes", + dest="assume_yes", + action="store_true", + default=False, + help="Answer yes to all prompts (install dependencies " + "without confirmation).", + ) parser.add_argument( "--skip-flutter-doctor", action="store_true", @@ -83,6 +94,7 @@ def handle(self, options: argparse.Namespace) -> None: self.options = options self.no_rich_output = self.no_rich_output or self.options.no_rich_output self.verbose = self.options.verbose + self.assume_yes = getattr(self.options, "assume_yes", False) def initialize_command(self): assert self.options @@ -113,6 +125,13 @@ def initialize_command(self): console.log("Dart executable:", self.dart_exe, style=verbose2_style) if self.require_android_sdk: + if not self._confirm_android_sdk_installation(): + self.skip_flutter_doctor = True + self.cleanup( + 1, + "Android SDK installation is required. " + "Re-run with --yes to install automatically.", + ) self.install_jdk() self.install_android_sdk() @@ -237,6 +256,35 @@ def install_android_sdk(self): if self.verbose > 0: console.log(f"Android SDK installed {self.emojis['checkmark']}") + def _confirm_android_sdk_installation(self) -> bool: + from flet_cli.utils.android_sdk import AndroidSDK + + if AndroidSDK.has_minimal_packages_installed(): + self._android_install_confirmed = True + return True + if self._android_install_confirmed: + return True + if self.assume_yes: + self._android_install_confirmed = True + return True + + prompt = ( + "\nAndroid SDK is required. If it's missing or incomplete, " + "it will be installed now. Proceed? [y/N] " + ) + + if self._prompt_input(prompt): + self._android_install_confirmed = True + return True + return False + + def _prompt_input(self, prompt: str) -> bool: + self.live.stop() + try: + return Confirm.ask(prompt, default=True) + finally: + self.live.start() + def find_flutter_batch(self, exe_filename: str): batch_path = shutil.which(exe_filename) if not batch_path: diff --git a/sdk/python/packages/flet-cli/src/flet_cli/utils/android_sdk.py b/sdk/python/packages/flet-cli/src/flet_cli/utils/android_sdk.py index b3b37d79b..d1dfd3b8e 100644 --- a/sdk/python/packages/flet-cli/src/flet_cli/utils/android_sdk.py +++ b/sdk/python/packages/flet-cli/src/flet_cli/utils/android_sdk.py @@ -84,6 +84,22 @@ def sdkmanager_exe(self, home_dir): assert bin return bin / self.tool_exe("sdkmanager", ".bat") + @staticmethod + def has_minimal_packages_installed() -> bool: + home_dir = AndroidSDK.android_home_dir() + if not home_dir: + return False + + sdk = AndroidSDK("", lambda *_: None) + if not sdk.cmdline_tools_bin(home_dir): + return False + + for package in MINIMAL_PACKAGES: + if not home_dir.joinpath(*package.split(";")).exists(): + return False + + return True + def cmdline_tools_url(self): try: url_platform = { From f71321a9e5f498fd8643d762946e24a23e0c4e82 Mon Sep 17 00:00:00 2001 From: Feodor Fitsner Date: Thu, 27 Nov 2025 16:03:19 -0800 Subject: [PATCH 15/22] Add emulator deletion support to CLI Introduces the --delete option to the emulators command, allowing users to delete Android emulators by ID or name. Refactors output formatting to optionally remove panel borders, updates device and emulator table layouts, and adds the delete_avd method to AndroidSDK for managing AVDs. --- .../flet-cli/src/flet_cli/commands/debug.py | 3 +- .../flet-cli/src/flet_cli/commands/devices.py | 6 +- .../src/flet_cli/commands/emulators.py | 94 +++++++++++++------ .../src/flet_cli/commands/flutter_base.py | 4 +- .../src/flet_cli/utils/android_sdk.py | 25 +++++ 5 files changed, 98 insertions(+), 34 deletions(-) diff --git a/sdk/python/packages/flet-cli/src/flet_cli/commands/debug.py b/sdk/python/packages/flet-cli/src/flet_cli/commands/debug.py index fdd6b73a8..b07d47dd4 100644 --- a/sdk/python/packages/flet-cli/src/flet_cli/commands/debug.py +++ b/sdk/python/packages/flet-cli/src/flet_cli/commands/debug.py @@ -5,7 +5,6 @@ from rich.console import Group from rich.live import Live -from rich.panel import Panel from flet_cli.commands.build_base import BaseBuildCommand, console, verbose2_style @@ -105,7 +104,7 @@ def handle(self, options: argparse.Namespace) -> None: self.customize_icons() self.customize_splash_images() self.run_flutter() - self.cleanup(0, message=Panel("Debug session ended.")) + self.cleanup(0, message="Debug session ended.") def check_device_id(self): if self.device_id is None and self.debug_platform in [ diff --git a/sdk/python/packages/flet-cli/src/flet_cli/commands/devices.py b/sdk/python/packages/flet-cli/src/flet_cli/commands/devices.py index 68b8d40be..5579ad614 100644 --- a/sdk/python/packages/flet-cli/src/flet_cli/commands/devices.py +++ b/sdk/python/packages/flet-cli/src/flet_cli/commands/devices.py @@ -123,7 +123,9 @@ def run_flutter_devices(self): if not devices: self.cleanup( - 0, Group(Panel(f"No {self.platform_label} devices found."), footer) + 0, + Group(Panel(f"No {self.platform_label} devices found."), footer), + no_border=True, ) devices_table = Table( @@ -144,7 +146,7 @@ def run_flutter_devices(self): device["details"], ) - self.cleanup(0, message=Group(devices_table, footer)) + self.cleanup(0, message=Group(devices_table, footer), no_border=True) def _parse_devices_output(self, output: str) -> list[dict]: devices = [] diff --git a/sdk/python/packages/flet-cli/src/flet_cli/commands/emulators.py b/sdk/python/packages/flet-cli/src/flet_cli/commands/emulators.py index 7eab8f126..d9c76bae7 100644 --- a/sdk/python/packages/flet-cli/src/flet_cli/commands/emulators.py +++ b/sdk/python/packages/flet-cli/src/flet_cli/commands/emulators.py @@ -1,5 +1,6 @@ import argparse import os +from pathlib import Path from rich.console import Group from rich.live import Live @@ -7,6 +8,7 @@ from rich.table import Column, Table from flet_cli.commands.build_base import BaseFlutterCommand, console, verbose2_style +from flet_cli.utils.android_sdk import AndroidSDK class Command(BaseFlutterCommand): @@ -19,6 +21,7 @@ def __init__(self, parser: argparse.ArgumentParser) -> None: self.launch_target = None self.cold_boot = False self.create_emulator = False + self.delete_emulator = None self.emulator_name = None def add_arguments(self, parser: argparse.ArgumentParser) -> None: @@ -42,6 +45,12 @@ def add_arguments(self, parser: argparse.ArgumentParser) -> None: default=False, help="Create a new emulator using Flutter defaults.", ) + parser.add_argument( + "--delete", + dest="delete", + type=str, + help="Delete an emulator by ID or name (Android only).", + ) parser.add_argument( "--name", dest="name", @@ -56,16 +65,18 @@ def handle(self, options: argparse.Namespace) -> None: self.launch_target = self.options.launch self.cold_boot = bool(self.options.cold) self.create_emulator = bool(self.options.create) + self.delete_emulator = self.options.delete self.emulator_name = self.options.name - if self.create_emulator and self.launch_target: - console.log( - "Please choose either --create or --launch, not both.", - style=verbose2_style, - ) - self.cleanup( - 1, Panel("Please choose either --create or --launch, not both.") - ) + selected_actions = [ + bool(self.create_emulator), + bool(self.launch_target), + bool(self.delete_emulator), + ] + if sum(selected_actions) > 1: + msg = "Please choose only one action: --create, --launch, or --delete." + console.log(msg, style=verbose2_style) + self.cleanup(1, msg) self.status = console.status( "[bold blue]Initializing environment...", @@ -73,6 +84,9 @@ def handle(self, options: argparse.Namespace) -> None: ) with Live(Group(self.status, self.progress), console=console) as self.live: self.initialize_command() + if self.delete_emulator: + self._delete_emulator() + return if self.create_emulator: self._create_emulator() return @@ -120,11 +134,11 @@ def _list_emulators(self): '\nRun [green]"flet emulators --create"[/green] ' "to create a new emulator.\n" ) - self.cleanup(0, Group(Panel("No emulators found."), footer)) + self.cleanup(0, Group(Panel("No emulators found."), footer), no_border=True) table = Table( - Column("Name", style="cyan", justify="left"), Column("ID", style="magenta", justify="left", no_wrap=True), + Column("Name", style="cyan", justify="left"), Column("Platform", style="green", justify="center"), Column("Manufacturer", style="yellow", justify="left"), title="Available emulators", @@ -134,8 +148,8 @@ def _list_emulators(self): for emulator in emulators: table.add_row( - emulator["name"], emulator["id"], + emulator["name"], emulator["platform_label"], emulator["manufacturer"], ) @@ -146,13 +160,15 @@ def _list_emulators(self): '[green]"flet emulators --launch "[/green].\n' "Create a new emulator with " '[green]"flet emulators --create [--name ]"[/green].\n' + "Delete an Android emulator with " + '[green]"flet emulators --delete "[/green].\n' "\n" "You can find more information on managing emulators at the links below:\n" " https://developer.android.com/studio/run/managing-avds\n" " https://developer.android.com/studio/command-line/avdmanager" ) - self.cleanup(0, message=Group(table, footer)) + self.cleanup(0, message=Group(table, footer), no_border=True) def _launch_emulator(self): assert self.launch_target @@ -181,8 +197,10 @@ def _launch_emulator(self): error_output = launch_result.stderr or output self.cleanup( launch_result.returncode, - Panel( - error_output or f"Failed to launch emulator '{self.launch_target}'." + ( + error_output + if error_output + else f"Failed to launch emulator '{self.launch_target}'." ), ) return @@ -191,12 +209,33 @@ def _launch_emulator(self): console.log(output, style=verbose2_style) mode = " (cold boot)" if self.cold_boot else "" - self.cleanup( - 0, - Panel( - f"Emulator [cyan]{self.launch_target}[/cyan] launched{mode}.", - ), + self.cleanup(0, f"Emulator [cyan]{self.launch_target}[/cyan] launched{mode}.") + + def _delete_emulator(self): + assert self.delete_emulator + self.update_status( + f"[bold blue]Deleting emulator [cyan]{self.delete_emulator}[/cyan]..." ) + home_dir = self.env.get("ANDROID_HOME") or AndroidSDK.android_home_dir() + if not home_dir: + self.cleanup( + 1, "ANDROID_HOME is not set and Android SDK location cannot be found." + ) + + sdk = AndroidSDK( + self.env.get("JAVA_HOME", ""), self.log_stdout, progress=self.progress + ) + + try: + sdk.delete_avd(Path(home_dir), self.delete_emulator) + except Exception as exc: # pragma: no cover - defensive + self.skip_flutter_doctor = True + self.cleanup( + 1, f"Failed to delete emulator '{self.delete_emulator}': {exc}" + ) + return + + self.cleanup(0, f"Deleted emulator [cyan]{self.delete_emulator}[/cyan].") def _create_emulator(self): self.update_status("[bold blue]Creating emulator...") @@ -220,7 +259,7 @@ def _create_emulator(self): error_output = create_result.stderr or output self.cleanup( create_result.returncode, - Panel(error_output or "Failed to create emulator."), + error_output or "Failed to create emulator.", ) return @@ -230,11 +269,9 @@ def _create_emulator(self): created_name = self.emulator_name or "emulator" self.cleanup( 0, - Panel( - f"Created emulator [cyan]{created_name}[/cyan]. " - "Use `flet emulators` to list it or " - f"`flet emulators --launch {created_name}` to start it.", - ), + f"Created emulator [cyan]{created_name}[/cyan]. " + "Use `flet emulators` to list it or " + f"`flet emulators --launch {created_name}` to start it.", ) def _parse_emulators_output(self, output: str) -> list[dict]: @@ -247,10 +284,11 @@ def _parse_emulators_output(self, output: str) -> list[dict]: if len(parts) < 2: continue - name = parts[0] - emulator_id = parts[1] + emulator_id = parts[0] + name = parts[1] # Skip header rows printed by `flutter emulators` (Id • Name • Platform ...) - if name.lower() == "id" and emulator_id.lower() == "name": + lower_head = {p.lower() for p in parts[:4]} + if {"id", "name", "platform"}.issubset(lower_head): continue details_segments = parts[2:] platform_raw = details_segments[-1] if details_segments else "" diff --git a/sdk/python/packages/flet-cli/src/flet_cli/commands/flutter_base.py b/sdk/python/packages/flet-cli/src/flet_cli/commands/flutter_base.py index 70808328f..f428f7af8 100644 --- a/sdk/python/packages/flet-cli/src/flet_cli/commands/flutter_base.py +++ b/sdk/python/packages/flet-cli/src/flet_cli/commands/flutter_base.py @@ -305,10 +305,10 @@ def run(self, args, cwd, env: Optional[dict] = None, capture_output=True): log=self.log_stdout, ) - def cleanup(self, exit_code: int, message: Any = None): + def cleanup(self, exit_code: int, message: Any = None, no_border: bool = False): if exit_code == 0: self.live.update( - message if message else "", + (message if no_border else Panel(message)) if message else "", refresh=True, ) else: diff --git a/sdk/python/packages/flet-cli/src/flet_cli/utils/android_sdk.py b/sdk/python/packages/flet-cli/src/flet_cli/utils/android_sdk.py index d1dfd3b8e..5b681e029 100644 --- a/sdk/python/packages/flet-cli/src/flet_cli/utils/android_sdk.py +++ b/sdk/python/packages/flet-cli/src/flet_cli/utils/android_sdk.py @@ -84,6 +84,11 @@ def sdkmanager_exe(self, home_dir): assert bin return bin / self.tool_exe("sdkmanager", ".bat") + def avdmanager_exe(self, home_dir): + bin = self.cmdline_tools_bin(home_dir) + assert bin + return bin / self.tool_exe("avdmanager", ".bat") + @staticmethod def has_minimal_packages_installed() -> bool: home_dir = AndroidSDK.android_home_dir() @@ -100,6 +105,26 @@ def has_minimal_packages_installed() -> bool: return True + def delete_avd(self, home_dir: Path, avd_name: str) -> None: + """ + Deletes an Android Virtual Device using avdmanager. + """ + self.log(f'Deleting Android emulator "{avd_name}"') + result = self.run( + [ + self.avdmanager_exe(home_dir), + "delete", + "avd", + "-n", + avd_name, + ], + env={"ANDROID_HOME": str(home_dir)}, + capture_output=True, + ) + if result.returncode != 0: + self.log(result.stderr or result.stdout) + raise RuntimeError(f'Failed to delete Android emulator "{avd_name}"') + def cmdline_tools_url(self): try: url_platform = { From ebc7c67c8a1c41348566384a152930132c680bd7 Mon Sep 17 00:00:00 2001 From: Feodor Fitsner Date: Thu, 27 Nov 2025 16:10:19 -0800 Subject: [PATCH 16/22] Add docs for flet devices and emulators CLI commands Added documentation pages for the 'flet devices' and 'flet emulators' CLI commands and updated mkdocs navigation to include them. --- sdk/python/packages/flet/docs/cli/flet-devices.md | 5 +++++ sdk/python/packages/flet/docs/cli/flet-emulators.md | 5 +++++ sdk/python/packages/flet/mkdocs.yml | 2 ++ 3 files changed, 12 insertions(+) create mode 100644 sdk/python/packages/flet/docs/cli/flet-devices.md create mode 100644 sdk/python/packages/flet/docs/cli/flet-emulators.md diff --git a/sdk/python/packages/flet/docs/cli/flet-devices.md b/sdk/python/packages/flet/docs/cli/flet-devices.md new file mode 100644 index 000000000..671bb5d77 --- /dev/null +++ b/sdk/python/packages/flet/docs/cli/flet-devices.md @@ -0,0 +1,5 @@ +--- +title: flet devices +--- + +{{ flet_cli_as_markdown("devices") }} diff --git a/sdk/python/packages/flet/docs/cli/flet-emulators.md b/sdk/python/packages/flet/docs/cli/flet-emulators.md new file mode 100644 index 000000000..a9b5344f0 --- /dev/null +++ b/sdk/python/packages/flet/docs/cli/flet-emulators.md @@ -0,0 +1,5 @@ +--- +title: flet emulators +--- + +{{ flet_cli_as_markdown("emulators") }} diff --git a/sdk/python/packages/flet/mkdocs.yml b/sdk/python/packages/flet/mkdocs.yml index 0a322cc63..2a84849f4 100644 --- a/sdk/python/packages/flet/mkdocs.yml +++ b/sdk/python/packages/flet/mkdocs.yml @@ -465,7 +465,9 @@ nav: - flet build: cli/flet-build.md - flet create: cli/flet-create.md - flet debug: cli/flet-debug.md + - flet devices: cli/flet-devices.md - flet doctor: cli/flet-doctor.md + - flet emulators: cli/flet-emulators.md - flet pack: cli/flet-pack.md - flet publish: cli/flet-publish.md - flet run: cli/flet-run.md From 0978de0b47e00da03983860df804d2b8e887edc9 Mon Sep 17 00:00:00 2001 From: Feodor Fitsner Date: Thu, 27 Nov 2025 16:30:02 -0800 Subject: [PATCH 17/22] Refactor emulator command arguments and actions Simplifies emulator command interface by replacing multiple flags (--launch, --create, --delete) with positional 'action' and 'emulator' arguments. Adds validation for emulator names, improves error handling, and updates help messages and output to reflect new usage. This change streamlines command usage and improves user feedback for invalid input. --- .../src/flet_cli/commands/emulators.py | 126 +++++++++--------- 1 file changed, 64 insertions(+), 62 deletions(-) diff --git a/sdk/python/packages/flet-cli/src/flet_cli/commands/emulators.py b/sdk/python/packages/flet-cli/src/flet_cli/commands/emulators.py index d9c76bae7..3955b0dc9 100644 --- a/sdk/python/packages/flet-cli/src/flet_cli/commands/emulators.py +++ b/sdk/python/packages/flet-cli/src/flet_cli/commands/emulators.py @@ -1,5 +1,6 @@ import argparse import os +import re from pathlib import Path from rich.console import Group @@ -18,65 +19,42 @@ class Command(BaseFlutterCommand): def __init__(self, parser: argparse.ArgumentParser) -> None: super().__init__(parser) - self.launch_target = None + self.action = None + self.emulator_target = None self.cold_boot = False - self.create_emulator = False - self.delete_emulator = None self.emulator_name = None def add_arguments(self, parser: argparse.ArgumentParser) -> None: parser.add_argument( - "--launch", - dest="launch", + "action", + type=str.lower, + nargs="?", + choices=["start", "create", "delete"], + help="Action to perform: start an emulator, create a new one, " + "or delete it.", + ) + parser.add_argument( + "emulator", type=str, - help="Launch a specific emulator by ID or name.", + nargs="?", + help="Emulator ID or name (required for start, create, and delete).", ) parser.add_argument( "--cold", dest="cold", action="store_true", default=False, - help="Cold boot the emulator when launching.", - ) - parser.add_argument( - "--create", - dest="create", - action="store_true", - default=False, - help="Create a new emulator using Flutter defaults.", - ) - parser.add_argument( - "--delete", - dest="delete", - type=str, - help="Delete an emulator by ID or name (Android only).", - ) - parser.add_argument( - "--name", - dest="name", - type=str, - help="Name for the new emulator (used with --create).", + help="Cold boot the emulator when starting.", ) super().add_arguments(parser) def handle(self, options: argparse.Namespace) -> None: super().handle(options) if self.options: - self.launch_target = self.options.launch + self.action = self.options.action + self.emulator_target = self.options.emulator + self.emulator_name = self.options.emulator self.cold_boot = bool(self.options.cold) - self.create_emulator = bool(self.options.create) - self.delete_emulator = self.options.delete - self.emulator_name = self.options.name - - selected_actions = [ - bool(self.create_emulator), - bool(self.launch_target), - bool(self.delete_emulator), - ] - if sum(selected_actions) > 1: - msg = "Please choose only one action: --create, --launch, or --delete." - console.log(msg, style=verbose2_style) - self.cleanup(1, msg) self.status = console.status( "[bold blue]Initializing environment...", @@ -84,13 +62,22 @@ def handle(self, options: argparse.Namespace) -> None: ) with Live(Group(self.status, self.progress), console=console) as self.live: self.initialize_command() - if self.delete_emulator: + if self.action == "delete": + if not self.emulator_target: + self.skip_flutter_doctor = True + self.cleanup(1, "Provide emulator ID or name to delete.") self._delete_emulator() return - if self.create_emulator: + if self.action == "create": + if not self.emulator_name: + self.skip_flutter_doctor = True + self.cleanup(1, "Provide emulator name to create.") self._create_emulator() return - if self.launch_target: + if self.action == "start": + if not self.emulator_target: + self.skip_flutter_doctor = True + self.cleanup(1, "Provide emulator ID or name to start.") self._launch_emulator() return self._list_emulators() @@ -131,7 +118,7 @@ def _list_emulators(self): emulators = self._parse_emulators_output(output) if not emulators: footer = ( - '\nRun [green]"flet emulators --create"[/green] ' + '\nRun [green]"flet emulators create "[/green] ' "to create a new emulator.\n" ) self.cleanup(0, Group(Panel("No emulators found."), footer), no_border=True) @@ -157,11 +144,11 @@ def _list_emulators(self): footer = ( "\n" "Launch an emulator with " - '[green]"flet emulators --launch "[/green].\n' + '[green]"flet emulators start "[/green].\n' "Create a new emulator with " - '[green]"flet emulators --create [--name ]"[/green].\n' + '[green]"flet emulators create "[/green].\n' "Delete an Android emulator with " - '[green]"flet emulators --delete "[/green].\n' + '[green]"flet emulators delete "[/green].\n' "\n" "You can find more information on managing emulators at the links below:\n" " https://developer.android.com/studio/run/managing-avds\n" @@ -171,15 +158,15 @@ def _list_emulators(self): self.cleanup(0, message=Group(table, footer), no_border=True) def _launch_emulator(self): - assert self.launch_target + assert self.emulator_target self.update_status( - f"[bold blue]Launching emulator [cyan]{self.launch_target}[/cyan]..." + f"[bold blue]Starting emulator [cyan]{self.emulator_target}[/cyan]..." ) args = [ self.flutter_exe, "emulators", "--launch", - self.launch_target, + self.emulator_target, "--no-version-check", "--suppress-analytics", ] @@ -200,7 +187,7 @@ def _launch_emulator(self): ( error_output if error_output - else f"Failed to launch emulator '{self.launch_target}'." + else f"Failed to start emulator '{self.emulator_target}'." ), ) return @@ -209,12 +196,12 @@ def _launch_emulator(self): console.log(output, style=verbose2_style) mode = " (cold boot)" if self.cold_boot else "" - self.cleanup(0, f"Emulator [cyan]{self.launch_target}[/cyan] launched{mode}.") + self.cleanup(0, f"Emulator [cyan]{self.emulator_target}[/cyan] started{mode}.") def _delete_emulator(self): - assert self.delete_emulator + assert self.emulator_target self.update_status( - f"[bold blue]Deleting emulator [cyan]{self.delete_emulator}[/cyan]..." + f"[bold blue]Deleting emulator [cyan]{self.emulator_target}[/cyan]..." ) home_dir = self.env.get("ANDROID_HOME") or AndroidSDK.android_home_dir() if not home_dir: @@ -227,18 +214,25 @@ def _delete_emulator(self): ) try: - sdk.delete_avd(Path(home_dir), self.delete_emulator) + sdk.delete_avd(Path(home_dir), self.emulator_target) except Exception as exc: # pragma: no cover - defensive self.skip_flutter_doctor = True self.cleanup( - 1, f"Failed to delete emulator '{self.delete_emulator}': {exc}" + 1, f"Failed to delete emulator '{self.emulator_target}': {exc}" ) return - self.cleanup(0, f"Deleted emulator [cyan]{self.delete_emulator}[/cyan].") + self.cleanup(0, f"Deleted emulator [cyan]{self.emulator_target}[/cyan].") def _create_emulator(self): self.update_status("[bold blue]Creating emulator...") + if not self._is_valid_emulator_name(self.emulator_name): + self.skip_flutter_doctor = True + self.cleanup( + 1, + "Emulator name is invalid. Allowed characters: a-z A-Z 0-9 . _ -", + ) + args = [ self.flutter_exe, "emulators", @@ -255,10 +249,15 @@ def _create_emulator(self): capture_output=True, ) output = create_result.stdout or "" - if create_result.returncode != 0: - error_output = create_result.stderr or output + error_output = create_result.stderr or output + invalid_name = ( + "contains invalid characters" in (error_output or "").lower() + or "contains invalid characters" in output.lower() + ) + exit_code = create_result.returncode or (1 if invalid_name else 0) + if exit_code != 0: self.cleanup( - create_result.returncode, + exit_code, error_output or "Failed to create emulator.", ) return @@ -266,14 +265,17 @@ def _create_emulator(self): if output and self.verbose >= 1: console.log(output, style=verbose2_style) - created_name = self.emulator_name or "emulator" + created_name = self.emulator_name self.cleanup( 0, f"Created emulator [cyan]{created_name}[/cyan]. " "Use `flet emulators` to list it or " - f"`flet emulators --launch {created_name}` to start it.", + f"`flet emulators start {created_name}` to start it.", ) + def _is_valid_emulator_name(self, name: str) -> bool: + return bool(re.match(r"^[A-Za-z0-9._-]+$", name or "")) + def _parse_emulators_output(self, output: str) -> list[dict]: emulators = [] for raw_line in output.splitlines(): From f00d36188dff3a658b5d65fd0e1cc1526c5e95e3 Mon Sep 17 00:00:00 2001 From: Feodor Fitsner Date: Sat, 29 Nov 2025 15:44:16 -0800 Subject: [PATCH 18/22] Improve Flutter SDK installation and detection Refactored Flutter SDK installation logic to prompt users before installing if the SDK is missing or invalid, and added get_flutter_dir utility for consistent SDK path resolution. Enhanced batch file detection to check the expected install directory first, and standardized user prompts for SDK installation. --- .../src/flet_cli/commands/flutter_base.py | 28 +++++++++++++++++-- .../flet-cli/src/flet_cli/utils/flutter.py | 10 +++++-- 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/sdk/python/packages/flet-cli/src/flet_cli/commands/flutter_base.py b/sdk/python/packages/flet-cli/src/flet_cli/commands/flutter_base.py index f428f7af8..d718b378d 100644 --- a/sdk/python/packages/flet-cli/src/flet_cli/commands/flutter_base.py +++ b/sdk/python/packages/flet-cli/src/flet_cli/commands/flutter_base.py @@ -18,6 +18,7 @@ from flet.utils import cleanup_path, is_windows from flet.utils.platform_utils import get_bool_env_var from flet_cli.commands.base import BaseCommand +from flet_cli.utils.flutter import get_flutter_dir, install_flutter PYODIDE_ROOT_URL = "https://cdn.jsdelivr.net/pyodide/v0.27.7/full" DEFAULT_TEMPLATE_URL = "gh:flet-dev/flet-build-template" @@ -118,6 +119,24 @@ def initialize_command(self): or not self.dart_exe or not self.flutter_version_valid() ): + if not self.assume_yes: + console.log( + "Flutter SDK not found or invalid version installed.", + style=warning_style, + ) + prompt = ( + "Flutter SDK " + f"{MINIMAL_FLUTTER_VERSION} is required. It will be installed now. " + "Proceed? [y/n] " + ) + + if not self._prompt_input(prompt): + self.skip_flutter_doctor = True + self.cleanup( + 1, + "Flutter SDK installation is required. " + "Re-run with --yes to install automatically.", + ) self.install_flutter() if self.verbose > 0: @@ -164,7 +183,6 @@ def install_flutter(self): self.update_status( f"[bold blue]Installing Flutter {MINIMAL_FLUTTER_VERSION}..." ) - from flet_cli.utils.flutter import install_flutter flutter_dir = install_flutter( str(MINIMAL_FLUTTER_VERSION), self.log_stdout, progress=self.progress @@ -270,7 +288,7 @@ def _confirm_android_sdk_installation(self) -> bool: prompt = ( "\nAndroid SDK is required. If it's missing or incomplete, " - "it will be installed now. Proceed? [y/N] " + "it will be installed now. Proceed? [y/n] " ) if self._prompt_input(prompt): @@ -286,6 +304,12 @@ def _prompt_input(self, prompt: str) -> bool: self.live.start() def find_flutter_batch(self, exe_filename: str): + install_dir = get_flutter_dir(str(MINIMAL_FLUTTER_VERSION)) + ext = ".bat" if is_windows() else "" + batch_path = os.path.join(install_dir, "bin", f"{exe_filename}{ext}") + if os.path.exists(batch_path): + return batch_path + batch_path = shutil.which(exe_filename) if not batch_path: return None diff --git a/sdk/python/packages/flet-cli/src/flet_cli/utils/flutter.py b/sdk/python/packages/flet-cli/src/flet_cli/utils/flutter.py index 46d915537..f70b96e40 100644 --- a/sdk/python/packages/flet-cli/src/flet_cli/utils/flutter.py +++ b/sdk/python/packages/flet-cli/src/flet_cli/utils/flutter.py @@ -4,10 +4,11 @@ from pathlib import Path from typing import Optional -from flet_cli.utils.distros import download_with_progress, extract_with_progress from rich.console import Console from rich.progress import Progress +from flet_cli.utils.distros import download_with_progress, extract_with_progress + def get_flutter_url(version): """Determines the Flutter archive URL based on the platform.""" @@ -28,9 +29,14 @@ def get_flutter_url(version): raise ValueError(f"Unsupported platform: {system}") +def get_flutter_dir(version): + home_dir = Path.home() + return os.path.join(home_dir, "flutter", version) + + def install_flutter(version, log, progress: Optional[Progress] = None): + install_dir = get_flutter_dir(version) home_dir = Path.home() - install_dir = os.path.join(home_dir, "flutter", version) if not os.path.exists(install_dir): url = get_flutter_url(version) From d3e4236d65267ef6819b6b1c93cd593c7ff6a051 Mon Sep 17 00:00:00 2001 From: Feodor Fitsner Date: Sun, 30 Nov 2025 09:39:52 -0800 Subject: [PATCH 19/22] Refactor version retrieval and clean up CLI commands Replaced usage of update_version() with from_git() for dynamic version retrieval throughout CLI and desktop modules. Removed unused imports and obsolete methods from build_base.py. Updated devices.py and emulators.py to import BaseFlutterCommand from flutter_base instead of build_base for better separation of concerns. --- .../packages/flet-cli/src/flet_cli/cli.py | 5 ++- .../flet-cli/src/flet_cli/commands/build.py | 23 +++++----- .../src/flet_cli/commands/build_base.py | 42 +++---------------- .../flet-cli/src/flet_cli/commands/devices.py | 6 +-- .../src/flet_cli/commands/emulators.py | 2 +- .../flet-desktop/src/flet_desktop/__init__.py | 3 +- sdk/python/packages/flet/src/flet/version.py | 4 +- 7 files changed, 24 insertions(+), 61 deletions(-) diff --git a/sdk/python/packages/flet-cli/src/flet_cli/cli.py b/sdk/python/packages/flet-cli/src/flet_cli/cli.py index 463997d29..7dff34ba3 100644 --- a/sdk/python/packages/flet-cli/src/flet_cli/cli.py +++ b/sdk/python/packages/flet-cli/src/flet_cli/cli.py @@ -12,7 +12,6 @@ import flet_cli.commands.publish import flet_cli.commands.run import flet_cli.commands.serve -from flet.version import update_version # Source https://stackoverflow.com/a/26379693 @@ -72,7 +71,9 @@ def get_parser() -> argparse.ArgumentParser: "--version", "-V", action="version", - version=flet.version.version if flet.version.version else update_version(), + version=flet.version.version + if flet.version.version + else flet.version.from_git(), ) sp = parser.add_subparsers(dest="command") diff --git a/sdk/python/packages/flet-cli/src/flet_cli/commands/build.py b/sdk/python/packages/flet-cli/src/flet_cli/commands/build.py index a67b1eabc..b6b5d4cc2 100644 --- a/sdk/python/packages/flet-cli/src/flet_cli/commands/build.py +++ b/sdk/python/packages/flet-cli/src/flet_cli/commands/build.py @@ -2,7 +2,6 @@ from rich.console import Group from rich.live import Live -from rich.panel import Panel from flet_cli.commands.build_base import BaseBuildCommand, console @@ -60,18 +59,16 @@ def handle(self, options: argparse.Namespace) -> None: self.cleanup( 0, message=( - Panel( - f"Successfully built your [cyan]" - f"{self.platforms[self.target_platform]['status_text']}" - f"[/cyan]! {self.emojis['success']} " - f"Find it in [cyan]{self.rel_out_dir}[/cyan] directory. " - f"{self.emojis['directory']}" - + ( - "\nRun [cyan]flet serve[/cyan] command to " - "start a web server with your app. " - if self.target_platform == "web" - else "" - ) + f"Successfully built your [cyan]" + f"{self.platforms[self.target_platform]['status_text']}" + f"[/cyan]! {self.emojis['success']} " + f"Find it in [cyan]{self.rel_out_dir}[/cyan] directory. " + f"{self.emojis['directory']}" + + ( + "\nRun [cyan]flet serve[/cyan] command to " + "start a web server with your app. " + if self.target_platform == "web" + else "" ) ), ) diff --git a/sdk/python/packages/flet-cli/src/flet_cli/commands/build_base.py b/sdk/python/packages/flet-cli/src/flet_cli/commands/build_base.py index 1286f5557..02744a14f 100644 --- a/sdk/python/packages/flet-cli/src/flet_cli/commands/build_base.py +++ b/sdk/python/packages/flet-cli/src/flet_cli/commands/build_base.py @@ -14,8 +14,7 @@ import flet.version import flet_cli.utils.processes as processes -from flet.utils import copy_tree, is_windows, slugify -from flet.version import update_version +from flet.utils import copy_tree, slugify from flet_cli.commands.flutter_base import ( BaseFlutterCommand, console, @@ -50,6 +49,7 @@ def __init__(self, parser: argparse.ArgumentParser) -> None: self.target_platform = None self.package_platform = None self.config_platform = None + self.debug_platform = None self.flutter_dependencies = {} self.package_app_path = None self.template_data = None @@ -1019,7 +1019,7 @@ def create_flutter_project(self, second_pass=False): template_ref = ( version.Version(flet.version.version).base_version if flet.version.version - else update_version() + else flet.version.from_git() ) hash.update(template_ref) @@ -1623,7 +1623,9 @@ def package_python_app(self): package_args.extend(["-r", "-r", "-r", str(requirements_txt)]) else: flet_version = ( - flet.version.version if flet.version.version else update_version() + flet.version.version + if flet.version.version + else flet.version.from_git() ) package_args.extend(["-r", f"flet=={flet_version}"]) @@ -1939,14 +1941,6 @@ def find_platform_image( return Path(images[0]).name return None - def find_flutter_batch(self, exe_filename: str): - batch_path = shutil.which(exe_filename) - if not batch_path: - return None - if is_windows() and batch_path.endswith(".file"): - return batch_path.replace(".file", ".bat") - return batch_path - def run(self, args, cwd, env: Optional[dict] = None, capture_output=True): if self.verbose > 0: console.log(f"Run subprocess: {args}", style=verbose1_style) @@ -1959,30 +1953,6 @@ def run(self, args, cwd, env: Optional[dict] = None, capture_output=True): log=self.log_stdout, ) - def run_flutter_doctor(self): - flutter_doctor = self.run( - [self.flutter_exe, "doctor", "--no-version-check", "--suppress-analytics"], - cwd=os.getcwd(), - capture_output=True, - ) - if flutter_doctor.returncode == 0 and flutter_doctor.stdout: - console.log(flutter_doctor.stdout, style=verbose1_style) - - def update_status(self, status): - if self.no_rich_output: - console.log(status) - else: - self.status.update(status) - - def log_stdout(self, message): - if self.verbose > 0: - console.log( - message, - end="", - style=verbose2_style, - markup=False, - ) - def load_yaml(self, path): with open(str(path), encoding="utf-8") as f: return yaml.safe_load(f) diff --git a/sdk/python/packages/flet-cli/src/flet_cli/commands/devices.py b/sdk/python/packages/flet-cli/src/flet_cli/commands/devices.py index 5579ad614..c491e95ce 100644 --- a/sdk/python/packages/flet-cli/src/flet_cli/commands/devices.py +++ b/sdk/python/packages/flet-cli/src/flet_cli/commands/devices.py @@ -6,7 +6,7 @@ from rich.panel import Panel from rich.table import Column, Table -from flet_cli.commands.build_base import BaseFlutterCommand, console, verbose2_style +from flet_cli.commands.flutter_base import BaseFlutterCommand, console, verbose2_style class Command(BaseFlutterCommand): @@ -63,10 +63,6 @@ def handle(self, options: argparse.Namespace) -> None: ) with Live(Group(self.status, self.progress), console=console) as self.live: self.initialize_command() - # if self.options.show_devices: - # self.run_flutter_devices() - # self.live.update("", refresh=True) - # return self.run_flutter_devices() self.cleanup(0) diff --git a/sdk/python/packages/flet-cli/src/flet_cli/commands/emulators.py b/sdk/python/packages/flet-cli/src/flet_cli/commands/emulators.py index 3955b0dc9..c826349b9 100644 --- a/sdk/python/packages/flet-cli/src/flet_cli/commands/emulators.py +++ b/sdk/python/packages/flet-cli/src/flet_cli/commands/emulators.py @@ -8,7 +8,7 @@ from rich.panel import Panel from rich.table import Column, Table -from flet_cli.commands.build_base import BaseFlutterCommand, console, verbose2_style +from flet_cli.commands.flutter_base import BaseFlutterCommand, console, verbose2_style from flet_cli.utils.android_sdk import AndroidSDK diff --git a/sdk/python/packages/flet-desktop/src/flet_desktop/__init__.py b/sdk/python/packages/flet-desktop/src/flet_desktop/__init__.py index 39f16197c..a61fe145a 100644 --- a/sdk/python/packages/flet-desktop/src/flet_desktop/__init__.py +++ b/sdk/python/packages/flet-desktop/src/flet_desktop/__init__.py @@ -210,9 +210,8 @@ def __download_flet_client(file_name): ver = flet_desktop.version.version if not ver: import flet.version - from flet.version import update_version - ver = flet.version.version or update_version() + ver = flet.version.version or flet.version.from_git() temp_arch = Path(tempfile.gettempdir()).joinpath(file_name) flet_url = f"https://github.com/flet-dev/flet/releases/download/v{ver}/{file_name}" logger.info(f"Downloading Flet v{ver} from {flet_url} to {temp_arch}") diff --git a/sdk/python/packages/flet/src/flet/version.py b/sdk/python/packages/flet/src/flet/version.py index d69895ad5..1796700f5 100644 --- a/sdk/python/packages/flet/src/flet/version.py +++ b/sdk/python/packages/flet/src/flet/version.py @@ -13,7 +13,7 @@ version = "" -def update_version(): +def from_git(): """Try to get the version from Git tags.""" working = Path().absolute() try: @@ -64,7 +64,7 @@ def find_repo_root(start_path: Path) -> Path | None: if not version and not is_mobile(): # Only try to get the version from Git if the pre-set version is empty # This is more likely to happen in a development/source environment - version = update_version() or DEFAULT_VERSION # Fallback to a default if Git fails + version = from_git() or DEFAULT_VERSION # Fallback to a default if Git fails # If 'version' is still empty after the above (e.g., in a built package # where CI didn't replace it), it might be appropriate to have another From 2f7b6c7f5859ce232db1e20a9b9e6f6a27c887d5 Mon Sep 17 00:00:00 2001 From: Feodor Fitsner Date: Sun, 30 Nov 2025 15:23:22 -0800 Subject: [PATCH 20/22] Handle ListTile click events in RadioControl Added support for responding to ListTile click events in RadioControl by listening to a notifier from ListTileClicks. This enables radio controls to react to tile clicks, updating the radio group selection if not disabled. --- packages/flet/lib/src/controls/radio.dart | 27 +++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/packages/flet/lib/src/controls/radio.dart b/packages/flet/lib/src/controls/radio.dart index 56bba7473..64ef533dd 100644 --- a/packages/flet/lib/src/controls/radio.dart +++ b/packages/flet/lib/src/controls/radio.dart @@ -9,6 +9,7 @@ import '../utils/text.dart'; import '../utils/theme.dart'; import '../widgets/error.dart'; import 'base_controls.dart'; +import 'list_tile.dart'; class RadioControl extends StatefulWidget { final Control control; @@ -22,6 +23,7 @@ class RadioControl extends StatefulWidget { class _RadioControlState extends State { late final FocusNode _focusNode; + Listenable? _tileClicksNotifier; @override void initState() { @@ -30,13 +32,38 @@ class _RadioControlState extends State { _focusNode.addListener(_onFocusChange); } + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final newNotifier = ListTileClicks.of(context)?.notifier; + + // If the inherited source changed, swap listeners + if (!identical(_tileClicksNotifier, newNotifier)) { + _tileClicksNotifier?.removeListener(_handleTileClick); + _tileClicksNotifier = newNotifier; + _tileClicksNotifier?.addListener(_handleTileClick); + } + } + void _onFocusChange() { widget.control.triggerEvent(_focusNode.hasFocus ? "focus" : "blur"); } + void _handleTileClick() { + if (widget.control.disabled) { + return; + } + final radioGroup = RadioGroup.maybeOf(context); + if (radioGroup != null) { + final value = widget.control.getString("value", "")!; + radioGroup.onChanged(value); + } + } + @override void dispose() { _focusNode.removeListener(_onFocusChange); + _tileClicksNotifier?.removeListener(_handleTileClick); _focusNode.dispose(); super.dispose(); } From 34ac8aecf9e1901d40bc7aafdce313aba1229416 Mon Sep 17 00:00:00 2001 From: Feodor Fitsner Date: Sun, 30 Nov 2025 15:41:58 -0800 Subject: [PATCH 21/22] Register path_provider_foundation plugin for macOS Added import and registration for the path_provider_foundation plugin in GeneratedPluginRegistrant.swift to enable path provider functionality on macOS. Also updated related dependencies in pubspec.lock. --- .../Flutter/GeneratedPluginRegistrant.swift | 2 ++ client/pubspec.lock | 24 ++++--------------- 2 files changed, 6 insertions(+), 20 deletions(-) diff --git a/client/macos/Flutter/GeneratedPluginRegistrant.swift b/client/macos/Flutter/GeneratedPluginRegistrant.swift index 3370f4918..18f174849 100644 --- a/client/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/client/macos/Flutter/GeneratedPluginRegistrant.swift @@ -12,6 +12,7 @@ import geolocator_apple import media_kit_libs_macos_video import media_kit_video import package_info_plus +import path_provider_foundation import record_macos import rive_common import screen_retriever_macos @@ -31,6 +32,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { MediaKitLibsMacosVideoPlugin.register(with: registry.registrar(forPlugin: "MediaKitLibsMacosVideoPlugin")) MediaKitVideoPlugin.register(with: registry.registrar(forPlugin: "MediaKitVideoPlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) + PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) RecordMacOsPlugin.register(with: registry.registrar(forPlugin: "RecordMacOsPlugin")) RivePlugin.register(with: registry.registrar(forPlugin: "RivePlugin")) ScreenRetrieverMacosPlugin.register(with: registry.registrar(forPlugin: "ScreenRetrieverMacosPlugin")) diff --git a/client/pubspec.lock b/client/pubspec.lock index 80f6d5aab..bc1b23761 100644 --- a/client/pubspec.lock +++ b/client/pubspec.lock @@ -189,10 +189,10 @@ packages: dependency: transitive description: name: device_info_plus - sha256: dd0e8e02186b2196c7848c9d394a5fd6e5b57a43a546082c5820b1ec72317e33 + sha256: "4df8babf73058181227e18b08e6ea3520cf5fc5d796888d33b7cb0f33f984b7c" url: "https://pub.dev" source: hosted - version: "12.2.0" + version: "12.3.0" device_info_plus_platform_interface: dependency: transitive description: @@ -782,14 +782,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.0" - objective_c: - dependency: transitive - description: - name: objective_c - sha256: "1f81ed9e41909d44162d7ec8663b2c647c202317cc0b56d3d56f6a13146a0b64" - url: "https://pub.dev" - source: hosted - version: "9.1.0" package_info_plus: dependency: transitive description: @@ -842,10 +834,10 @@ packages: dependency: transitive description: name: path_provider_foundation - sha256: "6192e477f34018ef1ea790c56fffc7302e3bc3efede9e798b934c252c8c105ba" + sha256: "6d13aece7b3f5c5a9731eaf553ff9dcbc2eff41087fd2df587fd0fed9a3eb0c4" url: "https://pub.dev" source: hosted - version: "2.5.0" + version: "2.5.1" path_provider_linux: dependency: transitive description: @@ -974,14 +966,6 @@ packages: url: "https://pub.dev" source: hosted version: "6.1.5+1" - pub_semver: - dependency: transitive - description: - name: pub_semver - sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" - url: "https://pub.dev" - source: hosted - version: "2.2.0" record: dependency: transitive description: From dd0dec15dff0767e35eba030895fcce2d8d86255 Mon Sep 17 00:00:00 2001 From: Feodor Fitsner Date: Mon, 1 Dec 2025 11:27:05 -0800 Subject: [PATCH 22/22] Fix image rendering in MatplotlibChart Replaces 'src_bytes' with 'src' when creating the Image object to ensure proper rendering of chart images in the MatplotlibChart component. --- .../packages/flet-charts/src/flet_charts/matplotlib_chart.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdk/python/packages/flet-charts/src/flet_charts/matplotlib_chart.py b/sdk/python/packages/flet-charts/src/flet_charts/matplotlib_chart.py index 7160562e2..de43034f6 100644 --- a/sdk/python/packages/flet-charts/src/flet_charts/matplotlib_chart.py +++ b/sdk/python/packages/flet-charts/src/flet_charts/matplotlib_chart.py @@ -323,7 +323,7 @@ async def _receive_loop(self): self.canvas.shapes = [ fc.Image( - src_bytes=content, + src=content, x=0, y=0, width=self.figure.bbox.size[0] / self.__dpr,