diff --git a/.fvmrc b/.fvmrc index dceb80ddbd..9bf4ad97a8 100644 --- a/.fvmrc +++ b/.fvmrc @@ -1,3 +1,3 @@ { - "flutter": "3.38.2" + "flutter": "3.38.3" } diff --git a/client/macos/Flutter/GeneratedPluginRegistrant.swift b/client/macos/Flutter/GeneratedPluginRegistrant.swift index 3370f49189..18f1748490 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 80f6d5aabd..bc1b237618 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: diff --git a/packages/flet/lib/src/controls/radio.dart b/packages/flet/lib/src/controls/radio.dart index 56bba74733..64ef533ddc 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(); } 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 7160562e2d..de43034f6a 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, 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 65f3b05939..7dff34ba3b 100644 --- a/sdk/python/packages/flet-cli/src/flet_cli/cli.py +++ b/sdk/python/packages/flet-cli/src/flet_cli/cli.py @@ -4,12 +4,14 @@ import flet.version import flet_cli.commands.build import flet_cli.commands.create -import flet_cli.commands.doctor # Adding the doctor command +import flet_cli.commands.debug +import flet_cli.commands.devices +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 import flet_cli.commands.serve -from flet.version import update_version # Source https://stackoverflow.com/a/26379693 @@ -69,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") @@ -78,9 +82,12 @@ 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") + 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") # 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 575715fbec..b6b5d4cc2d 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,12 @@ 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.build_base import BaseBuildCommand, 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(BaseBuildCommand): """ Build a Flet Python app into a platform-specific executable or installable bundle. It supports building for desktop (macOS, Linux, Windows), web, @@ -62,206 +17,13 @@ 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", - type=str, + type=str.lower, 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,387 +32,17 @@ 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) - 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() @@ -661,1622 +53,84 @@ 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( 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" + "\nRun [cyan]flet serve[/cyan] command to " + "start a web server with your app. " + 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_command_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}") + args.append("--no-codesign") - 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") - ) + build_number = self.options.build_number or self.get_pyproject( + "tool.flet.build_number" ) + if build_number: + args.extend(["--build-number", str(build_number)]) - 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") - ) + build_version = ( + self.options.build_version + or self.get_pyproject("project.version") + or self.get_pyproject("tool.poetry.version") ) + if build_version: + args.extend(["--build-name", build_version]) - 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 + 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 [] + ): + args.append(arg) - 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") - ) + def run_flutter(self): + assert self.platforms + assert self.target_platform - ios_export_method = ( - self.options.ios_export_method - or self.get_pyproject("tool.flet.ios.export_method") - or "debugging" + self.update_status( + f"[bold blue]Building [cyan]" + f"{self.platforms[self.target_platform]['status_text']}[/cyan]..." ) - 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 {} + self._run_flutter_command() - 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") + console.log( + f"Built [cyan]{self.platforms[self.target_platform]['status_text']}" + f"[/cyan] {self.emojis['checkmark']}", ) - - 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]) - - 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) 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 0000000000..02744a14f3 --- /dev/null +++ b/sdk/python/packages/flet-cli/src/flet_cli/commands/build_base.py @@ -0,0 +1,1962 @@ +import argparse +import glob +import os +import platform +import shutil +from pathlib import Path +from typing import Optional, cast + +import yaml +from packaging import version +from packaging.requirements import Requirement +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, slugify +from flet_cli.commands.flutter_base import ( + BaseFlutterCommand, + console, + error_style, + 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.pubspec_path = None + self.rel_out_dir = None + self.assets_path = 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 + self.python_module_filename = None + self.out_dir = None + self.python_module_name = None + self.get_pyproject = None + self.python_app_path = None + self.build_dir = None + self.flutter_dir: Optional[Path] = None + self.flutter_packages_dir = None + self.flutter_packages_temp_dir = None + 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", + 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)", + ) + 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", + type=str.lower, + 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_command(self): + assert self.options + assert self.target_platform + + 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() + + 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) + ): + 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 flet.version.from_git() + ) + 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 app shell 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 app shell {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 flet.version.from_git() + ) + 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 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 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/create.py b/sdk/python/packages/flet-cli/src/flet_cli/commands/create.py index 8f7d6b9265..c70760e177 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 new file mode 100644 index 0000000000..b07d47dd4d --- /dev/null +++ b/sdk/python/packages/flet-cli/src/flet_cli/commands/debug.py @@ -0,0 +1,156 @@ +import argparse +import contextlib +import os +import platform + +from rich.console import Group +from rich.live import Live + +from flet_cli.commands.build_base import BaseBuildCommand, console, verbose2_style + + +class Command(BaseBuildCommand): + """ + 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.debug_platform = None + self.device_id = None + + def add_arguments(self, parser: argparse.ArgumentParser) -> None: + parser.add_argument( + "platform", + type=str.lower, + 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( + "--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) + 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.platform_label = self.platform_labels[self.debug_platform] + 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", + ) + with Live(Group(self.status, self.progress), console=console) as self.live: + self.check_device_id() + self.initialize_command() + 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.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 [ + "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_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 + mode = "release" if self.options.release else "debug" + self.update_status( + f"[bold blue]Running {mode} version of the app on " + f"[cyan]{self.platform_label}[/cyan] device..." + ) + + 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( + [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/devices.py b/sdk/python/packages/flet-cli/src/flet_cli/commands/devices.py new file mode 100644 index 0000000000..c491e95ceb --- /dev/null +++ b/sdk/python/packages/flet-cli/src/flet_cli/commands/devices.py @@ -0,0 +1,191 @@ +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.flutter_base import BaseFlutterCommand, console, verbose2_style + + +class Command(BaseFlutterCommand): + """ + List all connected iOS and Android devices. + """ + + def __init__(self, parser: argparse.ArgumentParser) -> None: + super().__init__(parser) + self.devices_platform = None + self.device_timeout = 10 + self.device_connection = "default" + + def add_arguments(self, parser: argparse.ArgumentParser) -> None: + parser.add_argument( + "platform", + type=str.lower, + nargs="?", + choices=["ios", "android"], + help="The target platform to list devices for. " + "If not specified, lists all platforms.", + ) + 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", + type=str.lower, + 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.platform_label = self.platform_labels[self.devices_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() + self.run_flutter_devices() + self.cleanup(0) + + def initialize_command(self): + self.require_android_sdk = True + + super().initialize_command() + + def run_flutter_devices(self): + self.update_status( + f"[bold blue]Checking connected {self.platform_label} devices..." + ) + flutter_devices = self.run( + [ + self.flutter_exe, + "devices", + "--no-version-check", + "--suppress-analytics", + "--device-timeout", + str(self.device_timeout), + ], + cwd=os.getcwd(), + capture_output=True, + ) + 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.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] + + if not devices: + self.cleanup( + 0, + Group(Panel(f"No {self.platform_label} devices found."), footer), + no_border=True, + ) + + 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=f"Connected {self.platform_label} devices", + header_style="bold", + show_lines=True, + ) + + for device in devices: + devices_table.add_row( + device["id"], + device["name"], + device["platform_label"], + device["details"], + ) + + self.cleanup(0, message=Group(devices_table, footer), no_border=True) + + 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) + connection = self._detect_connection_type(parts) + + devices.append( + { + "name": name, + "id": device_id, + "platform": platform, + "platform_label": platform_raw, + "connection": connection, + "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 + + 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" 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 0000000000..c826349b97 --- /dev/null +++ b/sdk/python/packages/flet-cli/src/flet_cli/commands/emulators.py @@ -0,0 +1,321 @@ +import argparse +import os +import re +from pathlib import Path + +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.flutter_base import BaseFlutterCommand, console, verbose2_style +from flet_cli.utils.android_sdk import AndroidSDK + + +class Command(BaseFlutterCommand): + """ + List, create, and launch available emulators. + """ + + def __init__(self, parser: argparse.ArgumentParser) -> None: + super().__init__(parser) + self.action = None + self.emulator_target = None + self.cold_boot = False + self.emulator_name = None + + def add_arguments(self, parser: argparse.ArgumentParser) -> None: + parser.add_argument( + "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, + 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 starting.", + ) + super().add_arguments(parser) + + def handle(self, options: argparse.Namespace) -> None: + super().handle(options) + if self.options: + 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.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.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.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.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() + + def initialize_command(self): + self.require_android_sdk = True + + 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), no_border=True) + + table = Table( + 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", + header_style="bold", + show_lines=True, + ) + + for emulator in emulators: + table.add_row( + emulator["id"], + emulator["name"], + emulator["platform_label"], + emulator["manufacturer"], + ) + + footer = ( + "\n" + "Launch an emulator with " + '[green]"flet emulators start "[/green].\n' + "Create a new emulator with " + '[green]"flet emulators create "[/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), no_border=True) + + def _launch_emulator(self): + assert self.emulator_target + self.update_status( + f"[bold blue]Starting emulator [cyan]{self.emulator_target}[/cyan]..." + ) + args = [ + self.flutter_exe, + "emulators", + "--launch", + self.emulator_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, + ( + error_output + if error_output + else f"Failed to start emulator '{self.emulator_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, f"Emulator [cyan]{self.emulator_target}[/cyan] started{mode}.") + + def _delete_emulator(self): + assert self.emulator_target + self.update_status( + 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: + 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.emulator_target) + except Exception as exc: # pragma: no cover - defensive + self.skip_flutter_doctor = True + self.cleanup( + 1, f"Failed to delete emulator '{self.emulator_target}': {exc}" + ) + return + + 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", + "--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 "" + 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( + exit_code, + 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 + self.cleanup( + 0, + f"Created emulator [cyan]{created_name}[/cyan]. " + "Use `flet emulators` to list it or " + 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(): + 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 + + emulator_id = parts[0] + name = parts[1] + # Skip header rows printed by `flutter emulators` (Id • Name • Platform ...) + 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 "" + 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 new file mode 100644 index 0000000000..d718b378de --- /dev/null +++ b/sdk/python/packages/flet-cli/src/flet_cli/commands/flutter_base.py @@ -0,0 +1,387 @@ +import argparse +import os +import platform +import re +import shutil +import sys +from typing import Any, Optional + +from packaging import version +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 + +import flet_cli.utils.processes as processes +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" + +MINIMAL_FLUTTER_VERSION = version.Version("3.38.3") + +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 command. + """ + + def __init__(self, parser: argparse.ArgumentParser) -> None: + super().__init__(parser) + + self.env = {} + self.options = None + self.emojis = {} + self.dart_exe = None + self.flutter_exe = None + self.verbose = False + 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() + self.progress = Progress(transient=True) + self.platform_labels = { + "windows": "Windows", + "macos": "macOS", + "linux": "Linux", + "web": "Web", + "ios": "iOS", + "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( + "--no-rich-output", + action="store_true", + 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", + default=False, + help="Skip running Flutter doctor upon failed builds", + ) + + 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 + 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.skip_flutter_doctor = ( + self.skip_flutter_doctor or self.options.skip_flutter_doctor + ) + + # 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() + ): + 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: + console.log("Flutter executable:", self.flutter_exe, style=verbose2_style) + 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() + + 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}..." + ) + + 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 + 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", + style=verbose1_style, + ) + config_result = self.run( + [ + self.flutter_exe, + "config", + "--no-version-check", + "--suppress-analytics", + f"--enable-{desktop_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) + + 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 + + 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) + + 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 + + self.update_status("[bold blue]Installing Android SDK...") + self.env["ANDROID_HOME"] = AndroidSDK( + self.env["JAVA_HOME"], self.log_stdout, progress=self.progress + ).install() + + 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): + 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 + 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: Any = None, no_border: bool = False): + if exit_code == 0: + self.live.update( + (message if no_border else Panel(message)) if message else "", + 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, + ) 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 b28549a524..fd9f05e385 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", 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 b3b37d79b9..5b681e0299 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,47 @@ 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() + 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 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 = { 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 46d9155379..f70b96e404 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) 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 39f16197ca..a61fe145af 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/docs/cli/flet-debug.md b/sdk/python/packages/flet/docs/cli/flet-debug.md new file mode 100644 index 0000000000..1ad5bc5cae --- /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/docs/cli/flet-devices.md b/sdk/python/packages/flet/docs/cli/flet-devices.md new file mode 100644 index 0000000000..671bb5d77b --- /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 0000000000..a9b5344f05 --- /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/docs/publish/android.md b/sdk/python/packages/flet/docs/publish/android.md index 467687b06f..b276a1b1f9 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. diff --git a/sdk/python/packages/flet/mkdocs.yml b/sdk/python/packages/flet/mkdocs.yml index 245fb46a4c..2a84849f4e 100644 --- a/sdk/python/packages/flet/mkdocs.yml +++ b/sdk/python/packages/flet/mkdocs.yml @@ -464,7 +464,10 @@ nav: - Overview: cli/index.md - 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 diff --git a/sdk/python/packages/flet/src/flet/version.py b/sdk/python/packages/flet/src/flet/version.py index d69895ad52..1796700f54 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