diff --git a/cookiecutter.json b/cookiecutter.json index bff9d5c..e147a53 100644 --- a/cookiecutter.json +++ b/cookiecutter.json @@ -9,7 +9,6 @@ "copyright": "Copyright (c) 2023 Your Company", "sep": "/", "kotlin_dir": "{{ cookiecutter.org_name.replace('.', cookiecutter.sep) }}{{ cookiecutter.sep }}{{ cookiecutter.project_name }}{{ cookiecutter.sep }}", - "windows_tcp_port": 63777, "hide_loading_animation": true, "team_id": "", "base_url": "/", diff --git a/{{cookiecutter.out_dir}}/android/build.gradle b/{{cookiecutter.out_dir}}/android/build.gradle index f7eb7f6..ce647a4 100644 --- a/{{cookiecutter.out_dir}}/android/build.gradle +++ b/{{cookiecutter.out_dir}}/android/build.gradle @@ -6,7 +6,7 @@ buildscript { } dependencies { - classpath 'com.android.tools.build:gradle:7.3.0' + classpath 'com.android.tools.build:gradle:7.4.2' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" } } diff --git a/{{cookiecutter.out_dir}}/android/gradle/wrapper/gradle-wrapper.properties b/{{cookiecutter.out_dir}}/android/gradle/wrapper/gradle-wrapper.properties index 3c472b9..a0419d7 100644 --- a/{{cookiecutter.out_dir}}/android/gradle/wrapper/gradle-wrapper.properties +++ b/{{cookiecutter.out_dir}}/android/gradle/wrapper/gradle-wrapper.properties @@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-7.5-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-7.5-all.zip \ No newline at end of file diff --git a/{{cookiecutter.out_dir}}/lib/main.dart b/{{cookiecutter.out_dir}}/lib/main.dart index 388751a..07e31bd 100644 --- a/{{cookiecutter.out_dir}}/lib/main.dart +++ b/{{cookiecutter.out_dir}}/lib/main.dart @@ -1,14 +1,78 @@ +import 'dart:async'; import 'dart:io'; import 'package:flet/flet.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:path/path.dart' as path; import 'package:serious_python/serious_python.dart'; import 'package:url_strategy/url_strategy.dart'; const bool isProduction = bool.fromEnvironment('dart.vm.product'); +const assetPath = "app/app.zip"; +const pythonModuleName = "{{ cookiecutter.python_module_name }}"; +final hideLoadingPage = + bool.tryParse("{{ cookiecutter.hide_loading_animation }}".toLowerCase()) ?? + true; +const outLogFilename = "out.log"; +const errorExitCode = 100; + +const pythonScript = """ +import certifi, os, runpy, socket, sys, traceback + +os.environ["REQUESTS_CA_BUNDLE"] = certifi.where() +os.environ["SSL_CERT_FILE"] = certifi.where() + +if os.getenv("FLET_PLATFORM") == "android": + import ssl + + def create_default_context( + purpose=ssl.Purpose.SERVER_AUTH, *, cafile=None, capath=None, cadata=None + ): + return ssl.create_default_context( + purpose=purpose, cafile=certifi.where(), capath=capath, cadata=cadata + ) + + ssl._create_default_https_context = create_default_context + +out_file = open("$outLogFilename", "w+", buffering=1) + +callback_socket_addr = os.environ.get("FLET_PYTHON_CALLBACK_SOCKET_ADDR") +if ":" in callback_socket_addr: + addr, port = callback_socket_addr.split(":") + callback_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + callback_socket.connect((addr, int(port))) +else: + callback_socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + callback_socket.connect(callback_socket_addr) + +sys.stdout = sys.stderr = out_file + +def flet_exit(code=0): + callback_socket.sendall(str(code).encode()) + out_file.close() + callback_socket.close() + +sys.exit = flet_exit + +ex = None +try: + runpy.run_module("{module_name}", run_name="__main__") +except Exception as e: + ex = e + traceback.print_exception(e) + +sys.exit(0 if ex is None else $errorExitCode) +"""; + +// global vars +String pageUrl = ""; +String assetsDir = ""; +String appDir = ""; +Map environmentVariables = {}; + void main() async { if (isProduction) { // ignore: avoid_returning_null_for_void @@ -17,29 +81,49 @@ void main() async { runApp(FutureBuilder( future: prepareApp(), - builder: (BuildContext context, AsyncSnapshot> snapshot) { + builder: (BuildContext context, AsyncSnapshot snapshot) { if (snapshot.hasData) { - return FletApp( - pageUrl: snapshot.data![0], - assetsDir: snapshot.data![1], - hideLoadingPage: bool.tryParse( - "{{ cookiecutter.hide_loading_animation }}".toLowerCase()), - ); + // OK - start Python program + return kIsWeb + ? FletApp( + pageUrl: pageUrl, + assetsDir: assetsDir, + hideLoadingPage: hideLoadingPage, + ) + : FutureBuilder( + future: runPythonApp(), + builder: + (BuildContext context, AsyncSnapshot snapshot) { + if (snapshot.hasData || snapshot.hasError) { + // error or premature finish + return MaterialApp( + home: ErrorScreen( + title: "Error running app", + text: snapshot.data ?? snapshot.error.toString()), + ); + } else { + // no result of error + return FletApp( + pageUrl: pageUrl, + assetsDir: assetsDir, + hideLoadingPage: hideLoadingPage, + ); + } + }); } else if (snapshot.hasError) { - return Text('Error loading Flet app: ${snapshot.error}'); + // error + return MaterialApp( + home: ErrorScreen( + title: "Error starting app", + text: snapshot.error.toString())); } else { // loading - return const SizedBox.shrink(); + return const MaterialApp(home: BlankScreen()); } })); } -Future> prepareApp() async { - await setupDesktop(); - - String pageUrl = ""; - String assetsDir = ""; - +Future prepareApp() async { if (kIsWeb) { // web mode - connect via HTTP pageUrl = Uri.base.toString(); @@ -48,33 +132,163 @@ Future> prepareApp() async { setPathUrlStrategy(); } } else { + await setupDesktop(); + // extract app from asset - var appDir = await extractAssetZip("app/app.zip"); + appDir = await extractAssetZip(assetPath, checkHash: true); // set current directory to app path Directory.current = appDir; assetsDir = path.join(appDir, "assets"); - var environmentVariables = { - "FLET_PLATFORM": defaultTargetPlatform.name.toLowerCase() - }; + environmentVariables["FLET_PLATFORM"] = + defaultTargetPlatform.name.toLowerCase(); if (defaultTargetPlatform == TargetPlatform.windows) { // use TCP on Windows - var port = int.parse("{{ cookiecutter.windows_tcp_port }}"); - pageUrl = "tcp://localhost:$port"; - environmentVariables["FLET_SERVER_PORT"] = port.toString(); + var tcpPort = await getUnusedPort(); + pageUrl = "tcp://localhost:$tcpPort"; + environmentVariables["FLET_SERVER_PORT"] = tcpPort.toString(); } else { // use UDS on other platforms pageUrl = "flet.sock"; environmentVariables["FLET_SERVER_UDS_PATH"] = pageUrl; } - - SeriousPython.runProgram( - path.join(appDir, "{{ cookiecutter.python_module_name }}.pyc"), - environmentVariables: environmentVariables); } - return [pageUrl, assetsDir]; + return ""; +} + +Future runPythonApp() async { + var script = pythonScript.replaceAll('{module_name}', pythonModuleName); + + var completer = Completer(); + + ServerSocket outSocketServer; + String socketAddr = ""; + StringBuffer pythonOut = StringBuffer(); + + if (defaultTargetPlatform == TargetPlatform.windows) { + var tcpAddr = "127.0.0.1"; + outSocketServer = await ServerSocket.bind(tcpAddr, 0); + debugPrint( + 'Python output TCP Server is listening on port ${outSocketServer.port}'); + socketAddr = "$tcpAddr:${outSocketServer.port}"; + } else { + socketAddr = "stdout.sock"; + outSocketServer = await ServerSocket.bind( + InternetAddress(socketAddr, type: InternetAddressType.unix), 0); + debugPrint('Python output Socket Server is listening on $socketAddr'); + } + + environmentVariables["FLET_PYTHON_CALLBACK_SOCKET_ADDR"] = socketAddr; + + void closeOutServer() async { + outSocketServer.close(); + + int exitCode = int.tryParse(pythonOut.toString().trim()) ?? 0; + + if (exitCode == errorExitCode) { + var out = ""; + if (await File(outLogFilename).exists()) { + out = await File(outLogFilename).readAsString(); + } + completer.complete(out); + } else { + exit(exitCode); + } + } + + outSocketServer.listen((client) { + debugPrint( + 'Connection from: ${client.remoteAddress.address}:${client.remotePort}'); + client.listen((data) { + var s = String.fromCharCodes(data); + pythonOut.write(s); + }, onError: (error) { + client.close(); + closeOutServer(); + }, onDone: () { + client.close(); + closeOutServer(); + }); + }); + + // run python async + SeriousPython.runProgram(path.join(appDir, "$pythonModuleName.pyc"), + script: script, environmentVariables: environmentVariables); + + // wait for client connection to close + return completer.future; +} + +class ErrorScreen extends StatelessWidget { + final String title; + final String text; + + const ErrorScreen({super.key, required this.title, required this.text}); + + @override + Widget build(BuildContext context) { + return Scaffold( + body: SafeArea( + child: Container( + padding: const EdgeInsets.all(8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + title, + style: Theme.of(context).textTheme.titleMedium, + ), + TextButton.icon( + onPressed: () { + Clipboard.setData(ClipboardData(text: text)); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Copied to clipboard')), + ); + }, + icon: const Icon( + Icons.copy, + size: 16, + ), + label: const Text("Copy"), + ) + ], + ), + Expanded( + child: SingleChildScrollView( + child: SelectableText(text, + style: Theme.of(context).textTheme.bodySmall), + )) + ], + ), + )), + ); + } +} + +class BlankScreen extends StatelessWidget { + const BlankScreen({ + super.key, + }); + + @override + Widget build(BuildContext context) { + return const Scaffold( + body: SizedBox.shrink(), + ); + } +} + +Future getUnusedPort() { + return ServerSocket.bind("127.0.0.1", 0).then((socket) { + var port = socket.port; + socket.close(); + return port; + }); } diff --git a/{{cookiecutter.out_dir}}/macos/Podfile b/{{cookiecutter.out_dir}}/macos/Podfile index b52666a..dbccf89 100644 --- a/{{cookiecutter.out_dir}}/macos/Podfile +++ b/{{cookiecutter.out_dir}}/macos/Podfile @@ -1,4 +1,4 @@ -platform :osx, '10.15' +platform :osx, '11.0' # CocoaPods analytics sends network stats synchronously affecting flutter build latency. ENV['COCOAPODS_DISABLE_STATS'] = 'true' diff --git a/{{cookiecutter.out_dir}}/macos/Runner.xcodeproj/project.pbxproj b/{{cookiecutter.out_dir}}/macos/Runner.xcodeproj/project.pbxproj index fb3c8ef..13577b9 100644 --- a/{{cookiecutter.out_dir}}/macos/Runner.xcodeproj/project.pbxproj +++ b/{{cookiecutter.out_dir}}/macos/Runner.xcodeproj/project.pbxproj @@ -457,7 +457,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.15; + MACOSX_DEPLOYMENT_TARGET = 11.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = macosx; SWIFT_COMPILATION_MODE = wholemodule; @@ -536,7 +536,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.15; + MACOSX_DEPLOYMENT_TARGET = 11.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = macosx; @@ -583,7 +583,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.15; + MACOSX_DEPLOYMENT_TARGET = 11.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = macosx; SWIFT_COMPILATION_MODE = wholemodule; diff --git a/{{cookiecutter.out_dir}}/pubspec.yaml b/{{cookiecutter.out_dir}}/pubspec.yaml index 50315b6..12867c8 100644 --- a/{{cookiecutter.out_dir}}/pubspec.yaml +++ b/{{cookiecutter.out_dir}}/pubspec.yaml @@ -11,7 +11,7 @@ dependencies: flutter: sdk: flutter - serious_python: ^0.6.1 + serious_python: ^0.7.0 # serious_python: # git: # url: https://github.com/flet-dev/serious-python @@ -43,6 +43,7 @@ flutter: assets: - app/app.zip + - app/app.zip.hash # dart run flutter_launcher_icons flutter_launcher_icons: diff --git a/{{cookiecutter.out_dir}}/web/python-worker.js b/{{cookiecutter.out_dir}}/web/python-worker.js index 2983f34..130690b 100644 --- a/{{cookiecutter.out_dir}}/web/python-worker.js +++ b/{{cookiecutter.out_dir}}/web/python-worker.js @@ -9,13 +9,16 @@ self.initPyodide = async function () { self.pyodide.registerJsModule("flet_js", flet_js); flet_js.documentUrl = documentUrl; await self.pyodide.runPythonAsync(` - import sys + import sys, runpy, traceback from pyodide.http import pyfetch response = await pyfetch("assets/app/app.zip") await response.unpack_archive() sys.path.append("__pypackages__") + try: + runpy.run_module("${self.pythonModuleName}", run_name="__main__") + except Exception as e: + traceback.print_exception(e) `); - pyodide.pyimport(self.pythonModuleName); await self.flet_js.start_connection(self.receiveCallback); self.postMessage("initialized"); };