diff --git a/lib/src/authenticating_artifact_updater.dart b/lib/src/authenticating_artifact_updater.dart index b43df38..243aecb 100644 --- a/lib/src/authenticating_artifact_updater.dart +++ b/lib/src/authenticating_artifact_updater.dart @@ -70,6 +70,21 @@ class AuthenticatingArtifactUpdater implements ArtifactUpdater { @visibleForTesting final List downloadedFiles = []; + // Compat with Flutter 3.44+ ArtifactUpdater progress-context API. + @override + void setProgressContext({ + required int artifactIndex, + required int artifactTotal, + required int downloadTotal, + int downloadIndex = 0, + }) {} + + @override + void resetProgressContext() {} + + @override + String formatProgressMessage(String artifactName) => artifactName; + static const Set _denylistedBasenames = { 'entitlements.txt', 'without_entitlements.txt', diff --git a/lib/src/build_system/build_app.dart b/lib/src/build_system/build_app.dart index bb9b99a..a0390e1 100644 --- a/lib/src/build_system/build_app.dart +++ b/lib/src/build_system/build_app.dart @@ -51,6 +51,12 @@ class AppBuilder { ); artifacts ??= globals.flutterpiArtifacts; + _ensureLinuxNativeAssetsCompilerConfig( + outputDir: outDir, + buildInfo: buildInfo, + target: target, + ); + // We can still build debug for non-generic platforms of course, the correct // (generic) target must be chosen in the caller in that case. if (!target.isGeneric && buildInfo.mode == fl.BuildMode.debug) { @@ -161,6 +167,53 @@ class AppBuilder { return; } + void _ensureLinuxNativeAssetsCompilerConfig({ + required Directory outputDir, + required fl.BuildInfo buildInfo, + required FlutterpiTargetPlatform target, + }) { + final architecture = switch (target) { + FlutterpiTargetPlatform.genericX64 => 'x64', + FlutterpiTargetPlatform.genericRiscv64 => 'riscv64', + FlutterpiTargetPlatform.genericAArch64 || + FlutterpiTargetPlatform.pi3_64 || + FlutterpiTargetPlatform.pi4_64 => + 'arm64', + FlutterpiTargetPlatform.genericArmV7 || + FlutterpiTargetPlatform.pi3 || + FlutterpiTargetPlatform.pi4 => + // Flutter's native-assets API has no linux_arm target. Preserve the + // historical behavior until Flutter adds one. + 'arm64', + }; + final cmakeDirectory = outputDir + .childDirectory('linux') + .childDirectory(architecture) + .childDirectory(buildInfo.mode.cliName); + final cmakeCache = cmakeDirectory.childFile('CMakeCache.txt'); + if (cmakeCache.existsSync()) { + return; + } + + final clangPp = _operatingSystemUtils.which('clang++'); + final archiver = _operatingSystemUtils.which('ar'); + final linker = _operatingSystemUtils.which('ld'); + if (clangPp == null || archiver == null || linker == null) { + // Unit tests and builds without native-asset hooks don't need this + // compatibility file. If hooks are present, Flutter will report the + // missing toolchain when it tries to configure them. + return; + } + + cmakeDirectory.createSync(recursive: true); + cmakeCache.writeAsStringSync(''' +// Generated by flutterpi_tool for Flutter native-assets build hooks. +CMAKE_AR:FILEPATH=${archiver.path} +CMAKE_CXX_COMPILER:FILEPATH=${clangPp.path} +CMAKE_LINKER:FILEPATH=${linker.path} +'''); + } + Future buildBundle({ required String id, required FlutterpiHostPlatform host, diff --git a/lib/src/build_system/native_assets.dart b/lib/src/build_system/native_assets.dart new file mode 100644 index 0000000..ddb51d9 --- /dev/null +++ b/lib/src/build_system/native_assets.dart @@ -0,0 +1,47 @@ +import 'dart:convert'; + +/// Rewrites stock Linux bundled-code locations for a flutter-pi bundle. +/// +/// Flutter's Linux runner CMake installs `DynamicLoadingBundled` assets into a +/// directory covered by the runner's `$ORIGIN/lib` RUNPATH. The generated +/// manifest therefore encodes those basenames as `absolute`. flutter-pi has no +/// runner CMake install phase or equivalent RUNPATH; [copiedBasenames] are +/// installed in its bundle working directory instead. A `./` prefix is +/// required because Linux `dlopen` does not search the working directory for a +/// bare library basename. +/// +/// Only `absolute` entries whose basename was actually copied are changed. +/// System/relative/process/executable assets and unrelated absolute paths are +/// preserved exactly, apart from JSON whitespace. +String rewriteNativeAssetsManifestForFlutterPi( + String manifestJson, + Set copiedBasenames, +) { + final document = jsonDecode(manifestJson); + if (document is! Map) { + throw const FormatException( + 'Native Assets manifest root must be an object.', + ); + } + final nativeAssets = document['native-assets']; + if (nativeAssets is! Map) { + return jsonEncode(document); + } + + for (final targetAssets in nativeAssets.values) { + if (targetAssets is! Map) continue; + for (final location in targetAssets.values) { + if (location is! List || location.length != 2) continue; + final kind = location[0]; + final path = location[1]; + if (kind == 'absolute' && + path is String && + copiedBasenames.contains(path)) { + location[0] = 'relative'; + location[1] = './$path'; + } + } + } + + return jsonEncode(document); +} diff --git a/lib/src/build_system/targets.dart b/lib/src/build_system/targets.dart index e3544a0..5479564 100644 --- a/lib/src/build_system/targets.dart +++ b/lib/src/build_system/targets.dart @@ -4,6 +4,7 @@ import 'dart:async'; import 'package:flutterpi_tool/src/artifacts.dart'; import 'package:flutterpi_tool/src/build_system/extended_environment.dart'; +import 'package:flutterpi_tool/src/build_system/native_assets.dart'; import 'package:flutterpi_tool/src/cli/flutterpi_command.dart'; import 'package:flutterpi_tool/src/common.dart'; import 'package:flutterpi_tool/src/fltool/common.dart'; @@ -478,7 +479,9 @@ class CopyFlutterAssets extends Target { @override List get dependencies => [ + const DartBuildForNative(), const KernelSnapshot(), + const InstallCodeAssets(), ]; @override @@ -553,6 +556,47 @@ class CopyFlutterAssets extends Target { dartHookResult: dartHookResult, ); + // Flutter's stock Linux embedding finishes Native Assets installation in + // the runner's CMake install step: libraries built under + // native_assets/linux/ are copied next to the executable, matching the + // basename paths in NativeAssetsManifest.json. flutter-pi has no runner + // CMake phase, so perform that final placement here. Without it, hooks run + // and the manifest is generated, but @Native resolution fails at runtime. + if (layout == FilesystemLayout.flutterPi) { + final nativeAssetsDirectory = environment.outputDir + .childDirectory('native_assets') + .childDirectory('linux'); + final copiedBasenames = {}; + if (nativeAssetsDirectory.existsSync()) { + for (final entity in nativeAssetsDirectory.listSync()) { + if (entity is! File) { + continue; + } + final installed = outputDir.childFile(entity.basename); + entity.copySync(installed.path); + copiedBasenames.add(entity.basename); + depfile.inputs.add(entity); + depfile.outputs.add(installed); + } + } + + // installCodeAssets emits Linux's stock runner representation: + // ["absolute", "libfoo.so"]. The stock runner has an $ORIGIN/lib + // RUNPATH, but flutter-pi does not. Mark only the basenames installed + // above as explicit paths in the bundle working directory. The `./` + // prefix is significant: Linux dlopen does not search the working + // directory when given only a basename. + final manifest = outputDir.childFile('NativeAssetsManifest.json'); + if (manifest.existsSync() && copiedBasenames.isNotEmpty) { + manifest.writeAsStringSync( + rewriteNativeAssetsManifestForFlutterPi( + manifest.readAsStringSync(), + copiedBasenames, + ), + ); + } + } + environment.depFileService.writeToFile( depfile, environment.buildDir.childFile('flutter_assets.d'), diff --git a/lib/src/cli/command_runner.dart b/lib/src/cli/command_runner.dart index 16953df..a39863c 100644 --- a/lib/src/cli/command_runner.dart +++ b/lib/src/cli/command_runner.dart @@ -40,6 +40,34 @@ class FlutterpiToolCommandRunner extends CommandRunner 'Print the address of the Dart Tooling Daemon, if one is hosted by the Flutter CLI.', hide: !verboseHelp, ); + + argParser.addOption( + 'github-artifacts-repo', + valueHelp: 'owner/repository', + help: 'Download engine artifacts from a custom GitHub repository.', + hide: !verboseHelp, + ); + + argParser.addOption( + 'github-artifacts-runid', + valueHelp: 'run-id', + help: 'Download engine artifacts from a specific GitHub Actions run.', + hide: !verboseHelp, + ); + + argParser.addOption( + 'github-artifacts-engine-version', + valueHelp: 'engine-hash', + help: 'The engine version available in the selected workflow run.', + hide: !verboseHelp, + ); + + argParser.addOption( + 'github-artifacts-auth-token', + valueHelp: 'token', + help: 'GitHub token used to download workflow artifacts.', + hide: !verboseHelp, + ); } @override diff --git a/lib/src/cli/flutterpi_command.dart b/lib/src/cli/flutterpi_command.dart index cabdc2e..c90d5cd 100644 --- a/lib/src/cli/flutterpi_command.dart +++ b/lib/src/cli/flutterpi_command.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:args/command_runner.dart'; import 'package:file/file.dart'; +import 'package:flutterpi_tool/src/artifacts.dart'; import 'package:flutterpi_tool/src/cache.dart'; import 'package:flutterpi_tool/src/common.dart'; import 'package:flutterpi_tool/src/devices/flutterpi_ssh/device.dart'; @@ -42,10 +43,8 @@ mixin FlutterpiCommandMixin on fl.FlutterCommand { if (globals.platform.environment['GITHUB_TOKEN'] case final envToken?) { globals.logger.printTrace('Using GITHUB_TOKEN from environment.'); token = envToken; - } else if (argParser.options.containsKey('github-artifacts-auth-token')) { - token = stringArg('github-artifacts-auth-token'); } else { - token = null; + token = stringArg('github-artifacts-auth-token', global: true); } return MyGithub.caching( @@ -64,9 +63,10 @@ mixin FlutterpiCommandMixin on fl.FlutterCommand { required ProcessManager processManager, http.Client? httpClient, }) { - final repo = stringArg('github-artifacts-repo'); - final runId = stringArg('github-artifacts-runid'); - final githubEngineHash = stringArg('github-artifacts-engine-version'); + final repo = stringArg('github-artifacts-repo', global: true); + final runId = stringArg('github-artifacts-runid', global: true); + final githubEngineHash = + stringArg('github-artifacts-engine-version', global: true); if (runId != null) { return FlutterpiCache.fromWorkflow( @@ -456,6 +456,37 @@ mixin FlutterpiCommandMixin on fl.FlutterCommand { String? get debugLogsDirectoryPath => null; Future runWithContext(FutureOr Function() fn) async { + final usesCustomArtifacts = + stringArg('github-artifacts-repo', global: true) != null || + stringArg('github-artifacts-runid', global: true) != null || + stringArg('github-artifacts-engine-version', global: true) != null; + if (usesCustomArtifacts) { + _contextOverrides.putIfAbsent( + fl.Cache, + () => () => createCustomCache( + fs: globals.fs, + shutdownHooks: globals.shutdownHooks, + logger: globals.logger, + platform: globals.platform, + os: globals.os as MoreOperatingSystemUtils, + projectFactory: globals.projectFactory, + processManager: globals.processManager, + ), + ); + _contextOverrides.putIfAbsent( + fl.Artifacts, + () => () => CachedFlutterpiArtifacts( + inner: fl.CachedArtifacts( + fileSystem: globals.fs, + platform: globals.platform, + cache: globals.cache, + operatingSystemUtils: globals.os, + ), + cache: globals.flutterpiCache, + ), + ); + } + return fl.context.run( body: fn, overrides: _contextOverrides, diff --git a/lib/src/executable.dart b/lib/src/executable.dart index c9f5987..1540eef 100644 --- a/lib/src/executable.dart +++ b/lib/src/executable.dart @@ -70,7 +70,7 @@ Future main(List args) async { } await exitWithHooks( - 0, + e.exitCode ?? 1, shutdownHooks: globals.shutdownHooks, logger: globals.logger, ); @@ -79,7 +79,7 @@ Future main(List args) async { globals.printStatus(e.usage); await exitWithHooks( - 0, + 1, shutdownHooks: globals.shutdownHooks, logger: globals.logger, ); diff --git a/lib/src/more_os_utils.dart b/lib/src/more_os_utils.dart index ef75a78..b44974c 100644 --- a/lib/src/more_os_utils.dart +++ b/lib/src/more_os_utils.dart @@ -87,6 +87,7 @@ class MoreOperatingSystemUtilsWrapper implements MoreOperatingSystemUtils { HostPlatform.linux_arm64 => FlutterpiHostPlatform.linuxARM64, HostPlatform.windows_x64 => FlutterpiHostPlatform.windowsX64, HostPlatform.windows_arm64 => FlutterpiHostPlatform.windowsARM64, + _ => throw UnsupportedError('Unsupported host platform: $hostPlatform'), }; } diff --git a/test/native_assets_test.dart b/test/native_assets_test.dart new file mode 100644 index 0000000..1b830e7 --- /dev/null +++ b/test/native_assets_test.dart @@ -0,0 +1,76 @@ +import 'dart:convert'; + +import 'package:test/test.dart'; +import 'package:flutterpi_tool/src/build_system/native_assets.dart'; + +void main() { + test('rewrites only installed bundled Linux code assets', () { + final input = jsonEncode({ + 'format-version': [1, 0, 0], + 'native-assets': { + 'linux_arm64': { + 'package:webcrypto/lookup.dart': ['absolute', 'libwebcrypto.so'], + 'package:fllama/fllama.dart': ['absolute', 'libfllama.so'], + 'package:system/system.dart': ['absolute', '/usr/lib/libsystem.so'], + 'package:process/process.dart': ['process'], + 'package:relative/relative.dart': ['relative', 'already-relative.so'], + }, + }, + }); + + final rewritten = jsonDecode( + rewriteNativeAssetsManifestForFlutterPi(input, { + 'libwebcrypto.so', + 'libfllama.so', + }), + ) as Map; + final assets = (rewritten['native-assets'] + as Map)['linux_arm64'] as Map; + + expect(assets['package:webcrypto/lookup.dart'], [ + 'relative', + './libwebcrypto.so', + ]); + expect(assets['package:fllama/fllama.dart'], [ + 'relative', + './libfllama.so', + ]); + expect(assets['package:system/system.dart'], [ + 'absolute', + '/usr/lib/libsystem.so', + ]); + expect(assets['package:process/process.dart'], ['process']); + expect(assets['package:relative/relative.dart'], [ + 'relative', + 'already-relative.so', + ]); + }); + + test('preserves bundled entries that were not copied', () { + final input = jsonEncode({ + 'native-assets': { + 'linux_x64': { + 'package:missing/missing.dart': ['absolute', 'libmissing.so'], + }, + }, + }); + + final rewritten = jsonDecode( + rewriteNativeAssetsManifestForFlutterPi(input, {'libwebcrypto.so'}), + ) as Map; + final assets = (rewritten['native-assets'] + as Map)['linux_x64'] as Map; + + expect(assets['package:missing/missing.dart'], [ + 'absolute', + 'libmissing.so', + ]); + }); + + test('rejects a non-object manifest root', () { + expect( + () => rewriteNativeAssetsManifestForFlutterPi('[]', const {}), + throwsFormatException, + ); + }); +} diff --git a/test/src/fake_flutter_version.dart b/test/src/fake_flutter_version.dart index 6530353..8c1ce36 100644 --- a/test/src/fake_flutter_version.dart +++ b/test/src/fake_flutter_version.dart @@ -117,6 +117,9 @@ class FakeFlutterVersion implements fltool.FlutterVersion { @override Future ensureVersionFile() async {} + @override + void deleteVersionFile() {} + @override String getBranchName({bool redactUnknownBranches = false}) { if (!redactUnknownBranches || diff --git a/test/src/test_feature_flags.dart b/test/src/test_feature_flags.dart index 827bcc4..64dcd97 100644 --- a/test/src/test_feature_flags.dart +++ b/test/src/test_feature_flags.dart @@ -18,6 +18,8 @@ class TestFeatureFlags implements fl.FeatureFlags { this.isDartDataAssetsEnabled = false, this.isUISceneMigrationEnabled = false, this.isWindowingEnabled = false, + this.isAccessibilityEvaluationsEnabled = false, + this.isRiscv64SupportEnabled = false, }); @override @@ -68,6 +70,12 @@ class TestFeatureFlags implements fl.FeatureFlags { @override final bool isWindowingEnabled; + @override + final bool isAccessibilityEvaluationsEnabled; + + @override + final bool isRiscv64SupportEnabled; + @override bool isEnabled(fl.Feature feature) { return switch (feature) { @@ -87,6 +95,8 @@ class TestFeatureFlags implements fl.FeatureFlags { fl.dartDataAssets => isDartDataAssetsEnabled, fl.uiSceneMigration => isUISceneMigrationEnabled, fl.windowingFeature => isWindowingEnabled, + fl.accessibilityEvaluationsFeature => isAccessibilityEvaluationsEnabled, + fl.riscv64 => isRiscv64SupportEnabled, _ => false, }; } @@ -109,6 +119,8 @@ class TestFeatureFlags implements fl.FeatureFlags { fl.dartDataAssets, fl.uiSceneMigration, fl.windowingFeature, + fl.accessibilityEvaluationsFeature, + fl.riscv64, ]; @override