Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/examples.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ jobs:
- uses: nttld/setup-ndk@v1
id: setup-ndk
with:
ndk-version: r27c
ndk-version: r29
add-to-path: true
- name: flutter example
run: |
Expand Down
6 changes: 3 additions & 3 deletions .github/workflows/test_publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ jobs:
- uses: nttld/setup-ndk@v1
id: setup-ndk
with:
ndk-version: r27c
ndk-version: r29
add-to-path: true
- uses: subosito/flutter-action@v2
with:
Expand All @@ -47,7 +47,7 @@ jobs:
- uses: nttld/setup-ndk@v1
id: setup-ndk
with:
ndk-version: r27c
ndk-version: r29
add-to-path: true
- uses: subosito/flutter-action@v2
with:
Expand All @@ -66,7 +66,7 @@ jobs:
- uses: nttld/setup-ndk@v1
id: setup-ndk
with:
ndk-version: r27c
ndk-version: r29
add-to-path: true
- uses: subosito/flutter-action@v2
with:
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# native_toolchain_cmake

## 0.2.8

- fix: properly resolve user-defines for android_home on Windows, [#37](https://github.com/rainyl/native_toolchain_cmake/issues/37)

## 0.2.7

- new: add `CMakeBuilder.runStandalone` to allow running builder without `BuildInput input` and `BuildOutputBuilder output`
Expand Down
117 changes: 80 additions & 37 deletions lib/src/builder/builder.dart
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import 'dart:io';
import 'package:code_assets/code_assets.dart';
import 'package:hooks/hooks.dart';
import 'package:logging/logging.dart';
import 'package:meta/meta.dart';

import '../native_toolchain/msvc.dart';
import '../utils/env_from_bat.dart';
Expand Down Expand Up @@ -246,6 +247,50 @@ class CMakeBuilder implements Builder {
);
}

/// Unwraps the `workspace_pubspec.defines` envelope that `package:hooks`
/// wraps `user_defines` in.
///
/// When a build hook is invoked through `flutter build` / `hooks_runner`,
/// `input.json['user_defines']` has the shape:
///
/// ```json
/// {
/// "workspace_pubspec": {
/// "base_path": ".../pubspec.yaml",
/// "defines": { "env_file": ..., "android": {...}, ... }
/// }
/// }
/// ```
///
/// Direct callers of [runStandalone] usually pass the `defines` map directly
/// (without the envelope). This helper accepts both shapes and returns the
/// inner defines map. Non-Map values are logged and ignored.
@visibleForTesting
static Map<String, dynamic> unwrapUserDefinesForTesting(
Map<String, dynamic>? userDefines, {
Logger? logger,
}) => _unwrapUserDefines(userDefines, logger: logger);

static Map<String, dynamic> _unwrapUserDefines(Map<String, dynamic>? userDefines, {Logger? logger}) {
if (userDefines == null) return const {};
// Workspace envelope (hooks_runner / flutter build path).
final workspace = userDefines['workspace_pubspec'];
if (workspace is Map<String, dynamic>) {
final defines = workspace['defines'];
if (defines is Map<String, dynamic>) return defines;
if (defines is Map) return defines.cast<String, dynamic>();
if (defines != null) {
logger?.warning(
'user_defines.workspace_pubspec.defines expected Map, '
'got ${defines.runtimeType}; ignored.',
);
}
return const {};
}
// Direct flat defines (manual runStandalone / tests).
return userDefines;
}

/// Runs the CMake generate and build process with explicit arguments instead
/// of [BuildInput] or [BuildOutputBuilder].
///
Expand Down Expand Up @@ -296,60 +341,58 @@ class CMakeBuilder implements Builder {
// ninja_version: null # "1.10.2"
// windows:
// cmake_version: null # "3.31.6"
final androidConfig = userDefines["android"] as Map<String, dynamic>?;
final iosConfig = userDefines["ios"] as Map<String, dynamic>?;
final linuxConfig = userDefines["linux"] as Map<String, dynamic>?;
final macOSConfig = userDefines["macos"] as Map<String, dynamic>?;
final windowsConfig = userDefines["windows"] as Map<String, dynamic>?;

var cmakeVersion = userDefines["cmake_version"] as String?;
cmakeVersion = switch (targetOS) {
OS.android => androidConfig?["cmake_version"] as String? ?? cmakeVersion,
OS.iOS => iosConfig?["cmake_version"] as String? ?? cmakeVersion,
OS.linux => linuxConfig?["cmake_version"] as String? ?? cmakeVersion,
OS.macOS => macOSConfig?["cmake_version"] as String? ?? cmakeVersion,
OS.windows => windowsConfig?["cmake_version"] as String? ?? cmakeVersion,
_ => cmakeVersion,
};
logger?.fine('userDefines: $userDefines');

var ninjaVersion = userDefines["ninja_version"] as String?;
ninjaVersion = switch (targetOS) {
OS.android => androidConfig?["ninja_version"] as String? ?? ninjaVersion,
OS.iOS => iosConfig?["ninja_version"] as String? ?? ninjaVersion,
OS.linux => linuxConfig?["ninja_version"] as String? ?? ninjaVersion,
OS.macOS => macOSConfig?["ninja_version"] as String? ?? ninjaVersion,
OS.windows => windowsConfig?["ninja_version"] as String? ?? ninjaVersion,
_ => ninjaVersion,
};
// Peel `workspace_pubspec.defines` if present (hooks_runner / flutter
// build path). Direct callers passing the flat defines map are unaffected.
final userDefinesFlat = _unwrapUserDefines(userDefines, logger: logger);

var userConfig = UserConfig(
// Per-OS overrides win over the top-level entries; wrong types are logged
// and ignored instead of throwing. See [UserConfig.parseFromUserDefines].
var userConfig = UserConfig.parseFromUserDefines(
targetOS: targetOS,
cmakeVersion: cmakeVersion,
ninjaVersion: ninjaVersion,
ndkVersion: androidConfig?["ndk_version"] as String?,
androidHome: androidConfig?["android_home"] as String?,
preferAndroidNinja: userDefines["prefer_android_ninja"] as bool?,
preferAndroidCmake: userDefines["prefer_android_cmake"] as bool?,
userDefines: userDefinesFlat,
logger: logger,
);

// optional host specific build config
final envFile = userDefines["env_file"] as String?;

if (envFile != null) {
// Android-specific fallback chain for android_home:
// 1. user_defines.android.android_home (handled in parseFromUserDefines)
// 2. env_file ANDROID_HOME (handled below)
// 3. ANDROID_HOME system env var (handled below)
// Each step only fills in when the previous one is null.
final envFile = userDefinesFlat[UserConfigKeys.envFile];
if (envFile is String && envFile.isNotEmpty) {
final userEnvConfig = await getUserEnvConfig(input: input, packageRoot: packageRoot, envFile: envFile);
final androidHome = userEnvConfig['ANDROID_HOME'];
if (androidHome != null) {
if (androidHome != null && userConfig.androidHome == null) {
final androidHomeEntity = Directory(androidHome);
if (androidHomeEntity.existsSync()) {
userConfig = userConfig.copyWith(androidHome: androidHomeEntity.absolute.path);
} else {
logger?.warning(
"ANDROID_HOME=$androidHome is set in envFile=$envFile but does not exist, ignoring",
'ANDROID_HOME=$androidHome is set in envFile=$envFile but does not exist, ignoring',
);
}
}
} else if (envFile != null) {
logger?.warning('user_defines.env_file expected String, got ${envFile.runtimeType}; ignored.');
}

// Last-resort fallback to the ANDROID_HOME system environment variable.
if (userConfig.androidHome == null) {
final envAndroidHome = Platform.environment['ANDROID_HOME'];
if (envAndroidHome != null && envAndroidHome.isNotEmpty) {
final dir = Directory(envAndroidHome);
if (dir.existsSync()) {
userConfig = userConfig.copyWith(androidHome: dir.absolute.path);
} else {
logger?.warning('ANDROID_HOME=$envAndroidHome from environment does not exist, ignoring.');
}
}
}

logger?.fine('Resolved userConfig: $userConfig');

final task = RunCMakeBuilder(
targetOS: targetOS,
targetArchitecture: targetArchitecture,
Expand Down
154 changes: 152 additions & 2 deletions lib/src/builder/user_config.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,44 @@
import 'dart:io';

import 'package:code_assets/code_assets.dart';
import 'package:logging/logging.dart';

/// Keys allowed under the top-level `user_defines` map and (where applicable)
/// the per-OS sub-maps (`android`, `ios`, `linux`, `macos`, `windows`).
class UserConfigKeys {
const UserConfigKeys._();

static const cmakeVersion = 'cmake_version';
static const ninjaVersion = 'ninja_version';
static const ndkVersion = 'ndk_version';
static const androidHome = 'android_home';
static const envFile = 'env_file';
static const preferAndroidCmake = 'prefer_android_cmake';
static const preferAndroidNinja = 'prefer_android_ninja';

/// Per-OS sub-map keys indexed by [OS].
static const osConfigKey = {
OS.android: 'android',
OS.iOS: 'ios',
OS.linux: 'linux',
OS.macOS: 'macos',
OS.windows: 'windows',
};
}

class UserConfig {
final OS targetOS;

/// for [OS.android], i.e., ANDROID_HOME, will try to load from environment variable if not specified.
/// for [OS.android], i.e., ANDROID_HOME.
///
/// Always stored with forward slashes only and without a trailing slash so
/// it can be interpolated directly into glob patterns. `package:glob`
/// treats `\` as an escape character rather than a path separator, so raw
/// Windows paths obtained from `Platform.environment`, env files or
/// `Directory.absolute.path` need to be normalised at construction time.
/// Use [copyWith] / [parseFromUserDefines] to derive new instances so the
/// invariant is preserved; do not assign this field manually from a
/// non-normalised string.
final String? androidHome;

/// for [OS.android], if not specified, use the latest one
Expand Down Expand Up @@ -44,7 +77,111 @@ class UserConfig {
}) : preferAndroidCmake = preferAndroidCmake ?? targetOS == OS.android,
preferAndroidNinja = preferAndroidNinja ?? targetOS == OS.android,
androidHome =
androidHome ?? (envVarAndroidHomeAsDefault ? Platform.environment['ANDROID_HOME'] : null);
_normalizeAndroidHome(androidHome) ??
(envVarAndroidHomeAsDefault ? _normalizeAndroidHome(Platform.environment['ANDROID_HOME']) : null);

/// Normalises an [androidHome] path for safe interpolation into glob
/// patterns: backslashes become forward slashes and trailing slashes are
/// removed. `null` and empty strings are returned as `null`.
static String? _normalizeAndroidHome(String? path) {
if (path == null) return null;
final normalized = path.replaceAll(r'\', '/').replaceFirst(RegExp(r'/+$'), '');
return normalized.isEmpty ? null : normalized;
}

/// Parses the `user_defines` map (as surfaced by `hooks`) into a [UserConfig].
///
/// Resolution order for each scalar value:
/// 1. the per-OS sub-map (`android`, `ios`, ...) entry, if present;
/// 2. the top-level entry, if present;
/// 3. `null` (left to the caller / [UserConfig] defaults to fill in).
///
/// Keys with the wrong type are logged via [logger] at `warning` level and
/// ignored rather than throwing a [TypeError]. This mirrors the lenient
/// behaviour expected of build hooks where users edit YAML by hand.
///
/// Android-only keys ([ndkVersion], [androidHome]) are read solely from the
/// `android` sub-map regardless of [targetOS].
///
/// System environment variable `ANDROID_HOME` is **not** consulted here; the
/// caller decides whether to fall back to it. Pass
/// `envVarAndroidHomeAsDefault: true` (the default) to [UserConfig] if that
/// fallback is desired.
// ignore: prefer_constructors_over_static_methods
static UserConfig parseFromUserDefines({
required OS targetOS,
required Map<String, dynamic> userDefines,
Logger? logger,
}) {
final osConfigKey = UserConfigKeys.osConfigKey[targetOS];
final osConfig = osConfigKey == null ? null : _optMap(userDefines, osConfigKey, logger: logger);

final cmakeVersion =
_optString(osConfig, UserConfigKeys.cmakeVersion, logger: logger) ??
_optString(userDefines, UserConfigKeys.cmakeVersion, logger: logger);
final ninjaVersion =
_optString(osConfig, UserConfigKeys.ninjaVersion, logger: logger) ??
_optString(userDefines, UserConfigKeys.ninjaVersion, logger: logger);

// Android-only keys, read only from the android sub-map.
final androidConfig = _optMap(userDefines, UserConfigKeys.osConfigKey[OS.android]!, logger: logger);
final ndkVersion = _optString(androidConfig, UserConfigKeys.ndkVersion, logger: logger);
final androidHome = _optString(androidConfig, UserConfigKeys.androidHome, logger: logger);

final preferCmake =
_optBool(osConfig, UserConfigKeys.preferAndroidCmake, logger: logger) ??
_optBool(userDefines, UserConfigKeys.preferAndroidCmake, logger: logger);
final preferNinja =
_optBool(osConfig, UserConfigKeys.preferAndroidNinja, logger: logger) ??
_optBool(userDefines, UserConfigKeys.preferAndroidNinja, logger: logger);

return UserConfig(
targetOS: targetOS,
cmakeVersion: cmakeVersion,
ninjaVersion: ninjaVersion,
ndkVersion: ndkVersion,
androidHome: androidHome,
preferAndroidCmake: preferCmake,
preferAndroidNinja: preferNinja,
envVarAndroidHomeAsDefault: false,
);
}

/// Returns [map]?[key] when it is a [String], otherwise `null`.
///
/// Logs a warning when the entry exists but has the wrong type. Tolerates
/// numeric values (common from YAML where `3.22.1` parses as a double) by
/// accepting them.
static String? _optString(Map<String, dynamic>? map, String key, {Logger? logger}) {
if (map == null) return null;
final v = map[key];
if (v == null) return null;
if (v is String) return v;
if (v is num) return v.toString();
logger?.warning('user_defines.$key expected String or num, got ${v.runtimeType}; ignored.');
return null;
}

/// Returns [map]?[key] when it is a [bool], otherwise `null`.
static bool? _optBool(Map<String, dynamic>? map, String key, {Logger? logger}) {
if (map == null) return null;
final v = map[key];
if (v == null) return null;
if (v is bool) return v;
logger?.warning('user_defines.$key expected bool, got ${v.runtimeType}; ignored.');
return null;
}

/// Returns [map]?[key] when it is a [Map] with String keys, otherwise `null`.
static Map<String, dynamic>? _optMap(Map<String, dynamic>? map, String key, {Logger? logger}) {
if (map == null) return null;
final v = map[key];
if (v == null) return null;
if (v is Map<String, dynamic>) return v;
if (v is Map) return v.cast<String, dynamic>();
logger?.warning('user_defines.$key expected Map, got ${v.runtimeType}; ignored.');
return null;
}

UserConfig copyWith({
OS? targetOS,
Expand All @@ -62,5 +199,18 @@ class UserConfig {
ninjaVersion: ninjaVersion ?? this.ninjaVersion,
preferAndroidNinja: preferAndroidNinja ?? this.preferAndroidNinja,
ndkVersion: ndkVersion ?? this.ndkVersion,
envVarAndroidHomeAsDefault: false,
);

@override
String toString() =>
'UserConfig('
'targetOS: $targetOS, '
'cmakeVersion: $cmakeVersion, '
'ninjaVersion: $ninjaVersion, '
'ndkVersion: $ndkVersion, '
'androidHome: $androidHome, '
'preferAndroidCmake: $preferAndroidCmake, '
'preferAndroidNinja: $preferAndroidNinja'
')';
}
2 changes: 1 addition & 1 deletion pubspec.yaml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
name: native_toolchain_cmake
description: >-
A library to invoke and build CMake projects for Dart Native Assets.
version: 0.2.7
version: 0.2.8
repository: https://github.com/rainyl/native_toolchain_cmake

topics:
Expand Down
Loading
Loading