k3s-deployer 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1093 @@
1
+ const { hashText, shellQuote } = require('../shared/utils.cjs');
2
+
3
+ const MOBILE_BUILDER_IMAGE = 'ghcr.io/cirruslabs/flutter:stable';
4
+ const MOBILE_ANDROID_SDK_BUILDER_IMAGE = 'ghcr.io/cirruslabs/android-sdk:35';
5
+ const MOBILE_ANDROID_SDK_ARM64_BUILDER_IMAGE = 'eclipse-temurin:17-jdk-jammy';
6
+ const DEFAULT_MOBILE_BUILD_TIMEOUT_MS = 5 * 60 * 60 * 1000;
7
+ const MOBILE_BUILD_CACHE_FILE = '.k3s-deployer/mobile-build.json';
8
+ const FLUTTER_DOCKER_TARGET_PLATFORM = 'android-arm64';
9
+ const FLUTTER_DOCKER_BUILD_SCRIPT =
10
+ `echo '[mobile] Preparing Flutter builder environment (5%)'
11
+ format_duration() {
12
+ total="$1"
13
+ hours=$((total / 3600))
14
+ minutes=$(((total % 3600) / 60))
15
+ seconds=$((total % 60))
16
+ if [ "$hours" -gt 0 ]; then
17
+ printf '%dh %02dm' "$hours" "$minutes"
18
+ elif [ "$minutes" -gt 0 ]; then
19
+ printf '%dm %02ds' "$minutes" "$seconds"
20
+ else
21
+ printf '%ds' "$seconds"
22
+ fi
23
+ }
24
+ run_with_heartbeat() {
25
+ label="$1"
26
+ start_percent="$2"
27
+ end_percent="$3"
28
+ expected_seconds="$4"
29
+ shift 4
30
+ start_ts=$(date +%s)
31
+ (
32
+ while true; do
33
+ sleep 30
34
+ now_ts=$(date +%s)
35
+ elapsed=$((now_ts - start_ts))
36
+ bounded_elapsed=$elapsed
37
+ if [ "$bounded_elapsed" -gt "$expected_seconds" ]; then
38
+ bounded_elapsed=$expected_seconds
39
+ fi
40
+ progress=$((start_percent + (bounded_elapsed * (end_percent - start_percent)) / expected_seconds))
41
+ if [ "$progress" -ge "$end_percent" ]; then
42
+ progress=$((end_percent - 1))
43
+ fi
44
+ echo "[mobile] \${label} is still running (estimated \${progress}%, elapsed \$(format_duration "$elapsed"))"
45
+ done
46
+ ) &
47
+ heartbeat_pid=$!
48
+ "$@"
49
+ status=$?
50
+ kill "$heartbeat_pid" >/dev/null 2>&1 || true
51
+ wait "$heartbeat_pid" 2>/dev/null || true
52
+ return $status
53
+ }
54
+ mkdir -p "$GRADLE_USER_HOME" && cat > "$GRADLE_USER_HOME/gradle.properties" <<'EOF'
55
+ org.gradle.daemon=false
56
+ org.gradle.workers.max=2
57
+ kotlin.compiler.execution.strategy=out-of-process
58
+ org.gradle.jvmargs=-Xmx2g -Djdk.tls.client.protocols=TLSv1.2 -Dhttps.protocols=TLSv1.2
59
+ EOF
60
+ export JAVA_TOOL_OPTIONS="-Djdk.tls.client.protocols=TLSv1.2 -Dhttps.protocols=TLSv1.2"
61
+ export HOME=/workspace/.k3s-deployer/home
62
+ export XDG_CONFIG_HOME=$HOME/.config
63
+ export XDG_CACHE_HOME=$HOME/.cache
64
+ export FLUTTER_SKIP_UPDATE_CHECK=true
65
+ export PUB_ENVIRONMENT=bot.cli
66
+ mkdir -p "$HOME" "$XDG_CONFIG_HOME" "$XDG_CACHE_HOME"
67
+ if [ ! -e /lib64/ld-linux-x86-64.so.2 ] && [ -e /lib/x86_64-linux-gnu/ld-linux-x86-64.so.2 ]; then
68
+ mkdir -p /lib64
69
+ ln -sf /lib/x86_64-linux-gnu/ld-linux-x86-64.so.2 /lib64/ld-linux-x86-64.so.2
70
+ fi
71
+ export CI=true
72
+ echo '[mobile] Pre-caching Android artifacts (20%)'
73
+ run_with_heartbeat 'flutter precache' 20 30 1200 flutter precache --android --no-universal
74
+ echo '[mobile] Running flutter pub get (30%)'
75
+ run_with_heartbeat 'flutter pub get' 30 55 1200 flutter pub get
76
+ echo '[mobile] Building release APK for android-arm64 (55%)'
77
+ run_with_heartbeat 'Building release APK for android-arm64' 55 95 5400 flutter build apk --release --no-pub --target-platform ${FLUTTER_DOCKER_TARGET_PLATFORM}`;
78
+ const FLUTTER_ANDROID_SDK_BUILD_SCRIPT =
79
+ `echo '[mobile] Preparing Android SDK builder environment (5%)'
80
+ format_duration() {
81
+ total="$1"
82
+ hours=$((total / 3600))
83
+ minutes=$(((total % 3600) / 60))
84
+ seconds=$((total % 60))
85
+ if [ "$hours" -gt 0 ]; then
86
+ printf '%dh %02dm' "$hours" "$minutes"
87
+ elif [ "$minutes" -gt 0 ]; then
88
+ printf '%dm %02ds' "$minutes" "$seconds"
89
+ else
90
+ printf '%ds' "$seconds"
91
+ fi
92
+ }
93
+ run_with_heartbeat() {
94
+ label="$1"
95
+ start_percent="$2"
96
+ end_percent="$3"
97
+ expected_seconds="$4"
98
+ shift 4
99
+ start_ts=$(date +%s)
100
+ (
101
+ while true; do
102
+ sleep 30
103
+ now_ts=$(date +%s)
104
+ elapsed=$((now_ts - start_ts))
105
+ bounded_elapsed=$elapsed
106
+ if [ "$bounded_elapsed" -gt "$expected_seconds" ]; then
107
+ bounded_elapsed=$expected_seconds
108
+ fi
109
+ progress=$((start_percent + (bounded_elapsed * (end_percent - start_percent)) / expected_seconds))
110
+ if [ "$progress" -ge "$end_percent" ]; then
111
+ progress=$((end_percent - 1))
112
+ fi
113
+ echo "[mobile] \${label} is still running (estimated \${progress}%, elapsed \$(format_duration "$elapsed"))"
114
+ done
115
+ ) &
116
+ heartbeat_pid=$!
117
+ "$@"
118
+ status=$?
119
+ kill "$heartbeat_pid" >/dev/null 2>&1 || true
120
+ wait "$heartbeat_pid" 2>/dev/null || true
121
+ return $status
122
+ }
123
+ resolve_sdkmanager_bin() {
124
+ if command -v sdkmanager >/dev/null 2>&1; then
125
+ command -v sdkmanager
126
+ return 0
127
+ fi
128
+ for candidate in \
129
+ /opt/android-sdk-linux/cmdline-tools/latest/bin/sdkmanager \
130
+ /opt/android-sdk-linux/cmdline-tools/bin/sdkmanager \
131
+ /opt/android-sdk-linux/tools/bin/sdkmanager \
132
+ /opt/android-sdk-linux/latest/bin/sdkmanager
133
+ do
134
+ if [ -x "$candidate" ]; then
135
+ printf '%s\n' "$candidate"
136
+ return 0
137
+ fi
138
+ done
139
+ find /opt/android-sdk-linux -path '*/bin/sdkmanager' -type f 2>/dev/null | head -n 1
140
+ }
141
+ extract_ndk_version_from_file() {
142
+ file_path="$1"
143
+ if [ ! -f "$file_path" ]; then
144
+ return 0
145
+ fi
146
+ sed -nE "s/.*ndkVersion[[:space:]]*(=)?[[:space:]]*['\\\"]?([0-9]+(\\.[0-9]+)+)['\\\"]?.*/\\2/p" "$file_path" | head -n 1
147
+ }
148
+ uses_flutter_ndk_version() {
149
+ file_path="$1"
150
+ [ -f "$file_path" ] || return 1
151
+ grep -Eq 'ndkVersion[[:space:]]*(=)?[[:space:]]*flutter\\.ndkVersion' "$file_path"
152
+ }
153
+ resolve_flutter_ndk_version() {
154
+ for search_root in /opt/flutter/packages/flutter_tools/gradle /opt/flutter/packages/flutter_tools; do
155
+ if [ ! -d "$search_root" ]; then
156
+ continue
157
+ fi
158
+ version="$(grep -RhoE 'ndkVersion[^0-9]*[0-9]+(\\.[0-9]+)+' "$search_root" 2>/dev/null | grep -oE '[0-9]+(\\.[0-9]+)+' | head -n 1)"
159
+ if [ -n "$version" ]; then
160
+ printf '%s\n' "$version"
161
+ return 0
162
+ fi
163
+ done
164
+ }
165
+ resolve_required_ndk_version() {
166
+ flutter_managed_ndk=false
167
+ for gradle_file in /workspace/android/app/build.gradle.kts /workspace/android/app/build.gradle /workspace/app/build.gradle.kts /workspace/app/build.gradle; do
168
+ version="$(extract_ndk_version_from_file "$gradle_file")"
169
+ if [ -n "$version" ]; then
170
+ printf '%s\n' "$version"
171
+ return 0
172
+ fi
173
+ if uses_flutter_ndk_version "$gradle_file"; then
174
+ flutter_managed_ndk=true
175
+ fi
176
+ done
177
+ if [ "$flutter_managed_ndk" = true ]; then
178
+ resolve_flutter_ndk_version
179
+ fi
180
+ }
181
+ mkdir -p "$GRADLE_USER_HOME" && cat > "$GRADLE_USER_HOME/gradle.properties" <<'EOF'
182
+ org.gradle.daemon=false
183
+ org.gradle.workers.max=2
184
+ kotlin.compiler.execution.strategy=out-of-process
185
+ org.gradle.jvmargs=-Xmx2g -Djdk.tls.client.protocols=TLSv1.2 -Dhttps.protocols=TLSv1.2
186
+ android.builder.sdkDownload=false
187
+ EOF
188
+ export JAVA_TOOL_OPTIONS="-Djdk.tls.client.protocols=TLSv1.2 -Dhttps.protocols=TLSv1.2"
189
+ export PATH=/opt/flutter/bin:$PATH
190
+ export FLUTTER_ROOT=/opt/flutter
191
+ export ANDROID_HOME=/opt/android-sdk-linux
192
+ export ANDROID_SDK_ROOT=/opt/android-sdk-linux
193
+ export HOME=/workspace/.k3s-deployer/home
194
+ export XDG_CONFIG_HOME=$HOME/.config
195
+ export XDG_CACHE_HOME=$HOME/.cache
196
+ export FLUTTER_SKIP_UPDATE_CHECK=true
197
+ export PUB_ENVIRONMENT=bot.cli
198
+ export CI=true
199
+ mkdir -p "$HOME" "$XDG_CONFIG_HOME" "$XDG_CACHE_HOME"
200
+ if ! command -v git >/dev/null 2>&1; then
201
+ echo '[mobile] Installing git and basic tooling into Android SDK builder'
202
+ _need_apt_update=true
203
+ _newest_list=$(find /var/lib/apt/lists -maxdepth 1 -name '*.InRelease' -printf '%T@\n' 2>/dev/null | sort -n | tail -1)
204
+ if [ -n "$_newest_list" ] && [ "$(( $(date +%s) - \${_newest_list%.*} ))" -lt 86400 ]; then
205
+ _need_apt_update=false
206
+ fi
207
+ if [ "$_need_apt_update" = true ]; then
208
+ apt-get update
209
+ fi
210
+ apt-get install -y --no-install-recommends git ca-certificates curl unzip xz-utils zip
211
+ fi
212
+ if [ ! -f /opt/android-sdk-linux/cmdline-tools/latest/bin/sdkmanager ]; then
213
+ echo '[mobile] Downloading Android SDK command-line tools (5%)'
214
+ mkdir -p /opt/android-sdk-linux/cmdline-tools/latest
215
+ curl -fsSL https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip -o /tmp/cmdline-tools.zip
216
+ unzip -q /tmp/cmdline-tools.zip -d /tmp/cmdline-tools
217
+ mv /tmp/cmdline-tools/cmdline-tools/* /opt/android-sdk-linux/cmdline-tools/latest/
218
+ rm -rf /tmp/cmdline-tools.zip /tmp/cmdline-tools
219
+ fi
220
+ if [ -d /opt/flutter/.git ]; then
221
+ echo '[mobile] Reusing cached Flutter SDK (10%)'
222
+ elif [ ! -x /opt/flutter/bin/flutter ]; then
223
+ echo '[mobile] Cloning Flutter SDK into cache volume (10%)'
224
+ find /opt/flutter -mindepth 1 -maxdepth 1 -exec rm -rf {} +
225
+ git clone --depth 1 -b stable https://github.com/flutter/flutter.git /opt/flutter
226
+ fi
227
+ echo '[mobile] Flutter SDK ready (20%)'
228
+ flutter config --no-analytics >/dev/null 2>&1
229
+ if [ ! -e /lib64/ld-linux-x86-64.so.2 ]; then
230
+ echo '[mobile] Installing x86_64 glibc so NDK toolchain runs via QEMU binfmt'
231
+ dpkg --add-architecture amd64 2>/dev/null || true
232
+ sed -i 's|^deb http|deb [arch=arm64] http|g' /etc/apt/sources.list 2>/dev/null || true
233
+ for _sl in /etc/apt/sources.list.d/*.list; do
234
+ [ -f "$_sl" ] && sed -i 's|^deb http|deb [arch=arm64] http|g' "$_sl" 2>/dev/null || true
235
+ done
236
+ cat > /etc/apt/sources.list.d/ubuntu-amd64.list << 'AMDEOF'
237
+ deb [arch=amd64] http://archive.ubuntu.com/ubuntu jammy main restricted universe
238
+ deb [arch=amd64] http://archive.ubuntu.com/ubuntu jammy-updates main restricted universe
239
+ deb [arch=amd64] http://security.ubuntu.com/ubuntu jammy-security main restricted universe
240
+ AMDEOF
241
+ apt-get update -qq
242
+ apt-get install -y --no-install-recommends libc6:amd64 libstdc++6:amd64 zlib1g:amd64 libncurses6:amd64 libtinfo6:amd64
243
+ if [ -e /lib/x86_64-linux-gnu/ld-linux-x86-64.so.2 ]; then
244
+ mkdir -p /lib64
245
+ ln -sf /lib/x86_64-linux-gnu/ld-linux-x86-64.so.2 /lib64/ld-linux-x86-64.so.2
246
+ echo '[mobile] x86_64 dynamic linker ready at /lib64/'
247
+ fi
248
+ fi
249
+ _cmake_sdk=/opt/android-sdk-linux/cmake/3.22.1/bin/cmake
250
+ _ninja_sdk=/opt/android-sdk-linux/cmake/3.22.1/bin/ninja
251
+ if [ -f "$_cmake_sdk" ] && ! "$_cmake_sdk" --version >/dev/null 2>&1; then
252
+ echo '[mobile] SDK cmake cannot execute natively; installing ARM64 cmake/ninja'
253
+ apt-get install -y --no-install-recommends cmake ninja-build
254
+ _native_cmake=$(command -v cmake 2>/dev/null || true)
255
+ _native_ninja=$(command -v ninja 2>/dev/null || true)
256
+ if [ -n "$_native_cmake" ]; then
257
+ cp -f "$_native_cmake" "$_cmake_sdk"
258
+ echo "[mobile] Replaced SDK cmake with ARM64 binary"
259
+ fi
260
+ if [ -n "$_native_ninja" ]; then
261
+ cp -f "$_native_ninja" "$_ninja_sdk"
262
+ echo "[mobile] Replaced SDK ninja with ARM64 binary"
263
+ fi
264
+ fi
265
+ sdkmanager_bin="$(resolve_sdkmanager_bin)"
266
+ if [ -n "$sdkmanager_bin" ]; then
267
+ echo '[mobile] Accepting Android SDK licenses (22%)'
268
+ yes | "$sdkmanager_bin" --sdk_root="$ANDROID_SDK_ROOT" --licenses >/dev/null 2>&1 || true
269
+ required_ndk_version="$(resolve_required_ndk_version)"
270
+ if [ -n "$required_ndk_version" ]; then
271
+ ndk_dir="$ANDROID_SDK_ROOT/ndk/$required_ndk_version"
272
+ if [ ! -f "$ndk_dir/source.properties" ]; then
273
+ ndk_major="$(echo "$required_ndk_version" | cut -d. -f1)"
274
+ ndk_minor="$(echo "$required_ndk_version" | cut -d. -f2)"
275
+ case "$ndk_minor" in
276
+ 0) ndk_release="r\${ndk_major}" ;;
277
+ 1) ndk_release="r\${ndk_major}b" ;;
278
+ 2) ndk_release="r\${ndk_major}c" ;;
279
+ 3) ndk_release="r\${ndk_major}d" ;;
280
+ 4) ndk_release="r\${ndk_major}e" ;;
281
+ 5) ndk_release="r\${ndk_major}f" ;;
282
+ *) ndk_release="r\${ndk_major}" ;;
283
+ esac
284
+ ndk_url="https://dl.google.com/android/repository/android-ndk-\${ndk_release}-linux.zip"
285
+ ndk_zip="$ANDROID_SDK_ROOT/ndk-downloads/ndk-$required_ndk_version.zip"
286
+ echo "[mobile] Downloading Android NDK \$required_ndk_version via curl (resumable) (24%)"
287
+ download_ndk() {
288
+ curl -C - -fL --retry 3 --retry-delay 30 --connect-timeout 120 -o "$ndk_zip" "$ndk_url"
289
+ }
290
+ run_with_heartbeat 'Downloading Android NDK' 24 25 28800 download_ndk || true
291
+ if [ -f "$ndk_zip" ]; then
292
+ echo "[mobile] Extracting NDK \$required_ndk_version"
293
+ ndk_tmp="/tmp/ndk-extract-$$"
294
+ mkdir -p "$ndk_tmp"
295
+ if unzip -q "$ndk_zip" -d "$ndk_tmp" 2>/dev/null; then
296
+ ndk_src="$(find "$ndk_tmp" -maxdepth 1 -name 'android-ndk-*' -type d | head -n 1)"
297
+ if [ -n "$ndk_src" ] && [ -f "$ndk_src/source.properties" ]; then
298
+ mkdir -p "$ndk_dir"
299
+ cp -a "$ndk_src/." "$ndk_dir/"
300
+ rm -f "$ndk_zip"
301
+ echo "[mobile] NDK \$required_ndk_version installed"
302
+ else
303
+ echo "[mobile] NDK extraction incomplete, will retry next build" >&2
304
+ rm -rf "$ndk_dir" "$ndk_zip"
305
+ fi
306
+ else
307
+ echo "[mobile] NDK zip corrupt, deleting for fresh download next build" >&2
308
+ rm -f "$ndk_zip"
309
+ rm -rf "$ndk_dir"
310
+ fi
311
+ rm -rf "$ndk_tmp"
312
+ fi
313
+ fi
314
+ if [ ! -f "$ndk_dir/source.properties" ]; then
315
+ echo "[mobile] ERROR: Android NDK \$required_ndk_version could not be installed" >&2
316
+ exit 1
317
+ fi
318
+ fi
319
+ else
320
+ echo '[mobile] Android SDK manager not found, continuing without explicit license acceptance'
321
+ fi
322
+ if [ -n "$sdkmanager_bin" ]; then
323
+ _sdk_pkgs=""
324
+ for _plat in android-34 android-35; do
325
+ [ ! -f "$ANDROID_SDK_ROOT/platforms/$_plat/android.jar" ] && _sdk_pkgs="$_sdk_pkgs platforms;$_plat"
326
+ done
327
+ for _bt in 34.0.0 35.0.0; do
328
+ [ ! -f "$ANDROID_SDK_ROOT/build-tools/$_bt/aapt" ] && _sdk_pkgs="$_sdk_pkgs build-tools;$_bt"
329
+ done
330
+ [ ! -f "$ANDROID_SDK_ROOT/cmake/3.22.1/bin/cmake" ] && _sdk_pkgs="$_sdk_pkgs cmake;3.22.1"
331
+ _sdk_pkgs="\${_sdk_pkgs# }"
332
+ if [ -n "$_sdk_pkgs" ]; then
333
+ echo "[mobile] Installing Android SDK packages: $_sdk_pkgs (23%)"
334
+ _do_install_sdk() { yes | "$sdkmanager_bin" --sdk_root="$ANDROID_SDK_ROOT" $_sdk_pkgs; }
335
+ run_with_heartbeat 'Installing Android SDK packages' 23 25 1800 _do_install_sdk || echo '[mobile] WARNING: SDK install failed' >&2
336
+ fi
337
+ fi
338
+ echo '[mobile] Pre-caching Android artifacts (25%)'
339
+ run_with_heartbeat 'flutter precache' 25 30 1200 flutter precache --android --no-universal
340
+ echo '[mobile] Running flutter pub get (30%)'
341
+ run_with_heartbeat 'flutter pub get' 30 55 1200 flutter pub get
342
+ echo '[mobile] Building release APK for android-arm64 (55%)'
343
+ run_with_heartbeat 'Building release APK for android-arm64' 55 95 5400 flutter build apk --release --no-pub --target-platform ${FLUTTER_DOCKER_TARGET_PLATFORM}`;
344
+
345
+ async function readTextFile(runner, filePath, options = {}) {
346
+ const result = await runner.run(`cat ${shellQuote(filePath)}`, {
347
+ cwd: options.cwd,
348
+ dryRun: options.dryRun,
349
+ allowFailure: true,
350
+ timeoutMs: options.timeoutMs,
351
+ });
352
+ if (result.code !== 0) {
353
+ return null;
354
+ }
355
+ return result.stdout;
356
+ }
357
+
358
+ async function readJsonFile(runner, filePath, options = {}) {
359
+ const text = await readTextFile(runner, filePath, options);
360
+ if (!text) {
361
+ return null;
362
+ }
363
+ try {
364
+ return JSON.parse(text);
365
+ } catch {
366
+ return null;
367
+ }
368
+ }
369
+
370
+ async function findFirstFile(runner, searchRoot, pattern, options = {}) {
371
+ const result = await runner.run(
372
+ `find ${shellQuote(searchRoot)} -type f -name ${shellQuote(pattern)} | head -n 1`,
373
+ {
374
+ cwd: options.cwd,
375
+ dryRun: options.dryRun,
376
+ allowFailure: true,
377
+ timeoutMs: options.timeoutMs,
378
+ },
379
+ );
380
+ const filePath = String(result.stdout || '').trim();
381
+ return filePath || null;
382
+ }
383
+
384
+ async function findLatestFlutterArtifact(runner, unitPath, options = {}) {
385
+ const result = await runner.run(
386
+ `find ${shellQuote(`${unitPath}/build/app/outputs/flutter-apk`)} -maxdepth 1 -type f -name '*.apk' | sort | tail -n 1`,
387
+ {
388
+ cwd: options.cwd,
389
+ dryRun: options.dryRun,
390
+ allowFailure: true,
391
+ timeoutMs: options.timeoutMs,
392
+ },
393
+ );
394
+ const filePath = String(result.stdout || '').trim();
395
+ return filePath || null;
396
+ }
397
+
398
+ async function resolveFlutterArtifactPath(runner, unitPath, options = {}) {
399
+ return (
400
+ (await findLatestFlutterArtifact(runner, unitPath, options)) ||
401
+ `${unitPath}/build/app/outputs/flutter-apk/app-release.apk`
402
+ );
403
+ }
404
+
405
+ function normalizeArchitecture(value) {
406
+ const normalized = String(value || '').trim().toLowerCase();
407
+ if (normalized === 'aarch64' || normalized === 'arm64') {
408
+ return 'arm64';
409
+ }
410
+ if (normalized === 'x86_64' || normalized === 'amd64') {
411
+ return 'amd64';
412
+ }
413
+ return normalized || 'unknown';
414
+ }
415
+
416
+ async function detectHostArchitecture(runner, unitPath, options = {}) {
417
+ const result = await runner.run('uname -m', {
418
+ cwd: unitPath,
419
+ dryRun: options.dryRun,
420
+ allowFailure: true,
421
+ timeoutMs: options.timeoutMs,
422
+ });
423
+ return normalizeArchitecture(result.stdout);
424
+ }
425
+
426
+ async function resolveGitRevision(runner, workspacePath, options = {}) {
427
+ const result = await runner.run('git rev-parse HEAD', {
428
+ cwd: workspacePath,
429
+ dryRun: options.dryRun,
430
+ allowFailure: true,
431
+ timeoutMs: options.timeoutMs,
432
+ });
433
+ const revision = String(result.stdout || '').trim();
434
+ return revision || null;
435
+ }
436
+
437
+ function createFirebaseOptionsFile(androidConfig) {
438
+ const projectInfo = androidConfig.project_info || {};
439
+ const client = Array.isArray(androidConfig.client) ? androidConfig.client[0] || {} : {};
440
+ const clientInfo = client.client_info || {};
441
+ const apiKey = Array.isArray(client.api_key) ? client.api_key[0] || {} : {};
442
+
443
+ const values = {
444
+ apiKey: apiKey.current_key || '',
445
+ appId: clientInfo.mobilesdk_app_id || '',
446
+ messagingSenderId: projectInfo.project_number || '',
447
+ projectId: projectInfo.project_id || '',
448
+ storageBucket: projectInfo.storage_bucket || '',
449
+ };
450
+
451
+ return `import 'package:firebase_core/firebase_core.dart' show FirebaseOptions;
452
+ import 'package:flutter/foundation.dart' show TargetPlatform, defaultTargetPlatform, kIsWeb;
453
+
454
+ class DefaultFirebaseOptions {
455
+ static FirebaseOptions get currentPlatform {
456
+ if (kIsWeb) {
457
+ throw UnsupportedError('DefaultFirebaseOptions are not configured for web.');
458
+ }
459
+ switch (defaultTargetPlatform) {
460
+ case TargetPlatform.android:
461
+ return android;
462
+ case TargetPlatform.iOS:
463
+ throw UnsupportedError('DefaultFirebaseOptions are not configured for ios.');
464
+ case TargetPlatform.macOS:
465
+ throw UnsupportedError('DefaultFirebaseOptions are not configured for macos.');
466
+ case TargetPlatform.windows:
467
+ throw UnsupportedError('DefaultFirebaseOptions are not configured for windows.');
468
+ case TargetPlatform.linux:
469
+ throw UnsupportedError('DefaultFirebaseOptions are not configured for linux.');
470
+ default:
471
+ throw UnsupportedError('DefaultFirebaseOptions are not supported for this platform.');
472
+ }
473
+ }
474
+
475
+ static const FirebaseOptions android = FirebaseOptions(
476
+ apiKey: ${JSON.stringify(values.apiKey)},
477
+ appId: ${JSON.stringify(values.appId)},
478
+ messagingSenderId: ${JSON.stringify(values.messagingSenderId)},
479
+ projectId: ${JSON.stringify(values.projectId)},
480
+ storageBucket: ${JSON.stringify(values.storageBucket)},
481
+ );
482
+ }
483
+ `;
484
+ }
485
+
486
+ async function ensureFirebaseOptions(runner, unitPath, options = {}) {
487
+ const firebaseOptionsPath = `${unitPath}/lib/firebase_options.dart`;
488
+ const mainPath = `${unitPath}/lib/main.dart`;
489
+ const hasFirebaseOptions = await runner.exists(firebaseOptionsPath);
490
+ if (hasFirebaseOptions) {
491
+ return false;
492
+ }
493
+
494
+ const mainSource = await readTextFile(runner, mainPath, options);
495
+ if (!mainSource || !mainSource.includes('firebase_options.dart')) {
496
+ return false;
497
+ }
498
+
499
+ const googleServicesPath = await findFirstFile(
500
+ runner,
501
+ `${unitPath}/android`,
502
+ 'google-services.json',
503
+ options,
504
+ );
505
+
506
+ if (!googleServicesPath) {
507
+ throw new Error(
508
+ 'Flutter app imports lib/firebase_options.dart but no google-services.json was found to generate it',
509
+ );
510
+ }
511
+
512
+ const googleServicesText = await readTextFile(
513
+ runner,
514
+ googleServicesPath,
515
+ options,
516
+ );
517
+
518
+ if (!googleServicesText) {
519
+ throw new Error(
520
+ `Could not read ${googleServicesPath} to generate lib/firebase_options.dart`,
521
+ );
522
+ }
523
+
524
+ const androidConfig = JSON.parse(googleServicesText);
525
+ const firebaseOptionsContent = createFirebaseOptionsFile(androidConfig);
526
+ await runner.writeFile(firebaseOptionsPath, firebaseOptionsContent);
527
+ if (options.onLog) {
528
+ await options.onLog(
529
+ `Generated lib/firebase_options.dart from ${googleServicesPath.replace(`${unitPath}/`, '')}`,
530
+ );
531
+ }
532
+ return true;
533
+ }
534
+
535
+ async function commandSucceeds(runner, command, options = {}) {
536
+ try {
537
+ await runner.run(command, { ...options, allowFailure: false });
538
+ return true;
539
+ } catch {
540
+ return false;
541
+ }
542
+ }
543
+
544
+ async function findAndroidApplicationId(runner, unitPath, options = {}) {
545
+ for (const relativePath of [
546
+ 'android/app/build.gradle.kts',
547
+ 'android/app/build.gradle',
548
+ 'app/build.gradle.kts',
549
+ 'app/build.gradle',
550
+ ]) {
551
+ const source = await readTextFile(
552
+ runner,
553
+ `${unitPath}/${relativePath}`,
554
+ options,
555
+ );
556
+ if (!source) {
557
+ continue;
558
+ }
559
+ const match = source.match(
560
+ /applicationId\s*(?:=)?\s*["']([^"']+)["']/,
561
+ );
562
+ if (match) {
563
+ return match[1];
564
+ }
565
+ }
566
+ return null;
567
+ }
568
+
569
+ async function readMobileBuildCache(runner, workspacePath, options = {}) {
570
+ return readJsonFile(runner, `${workspacePath}/${MOBILE_BUILD_CACHE_FILE}`, options);
571
+ }
572
+
573
+ async function writeMobileBuildCache(
574
+ runner,
575
+ workspacePath,
576
+ payload,
577
+ ) {
578
+ await runner.writeFile(
579
+ `${workspacePath}/${MOBILE_BUILD_CACHE_FILE}`,
580
+ JSON.stringify(payload, null, 2),
581
+ );
582
+ }
583
+
584
+ function isGenSnapshotArtifactError(error) {
585
+ const message =
586
+ error instanceof Error ? error.message : String(error || '');
587
+ return (
588
+ message.includes('gen_snapshot') ||
589
+ message.includes('android_aot_release_') ||
590
+ message.includes('linux-arm64')
591
+ );
592
+ }
593
+
594
+ function isExecFormatError(error) {
595
+ const message =
596
+ error instanceof Error ? error.message : String(error || '');
597
+ return message.includes('exec format error');
598
+ }
599
+
600
+ function isAapt2LoaderError(error) {
601
+ const message =
602
+ error instanceof Error ? error.message : String(error || '');
603
+ return (
604
+ message.includes('AAPT2') ||
605
+ message.includes('ld-linux-x86-64.so.2') ||
606
+ message.includes('qemu-x86_64')
607
+ );
608
+ }
609
+
610
+ function isMissingImageManifestError(error) {
611
+ const message =
612
+ error instanceof Error ? error.message : String(error || '');
613
+ return message.includes('no matching manifest for linux/arm64');
614
+ }
615
+
616
+ async function runFlutterDockerBuild(
617
+ runner,
618
+ unitPath,
619
+ options = {},
620
+ platform,
621
+ ) {
622
+ const platformFlag = platform ? `--platform ${platform} ` : '';
623
+ const platformLabel = String(platform || 'native').replace(/[^a-z0-9]+/gi, '-');
624
+ const containerName = `k3s-mobile-flutter-${hashText(`${unitPath}-${platformLabel}`).slice(0, 12)}`;
625
+ const gradleHome = `/workspace/.gradle/mobile-flutter-${platformLabel}`;
626
+ await runner.run(`docker rm -f ${shellQuote(containerName)}`, {
627
+ cwd: unitPath,
628
+ dryRun: options.dryRun,
629
+ allowFailure: true,
630
+ timeoutMs: options.timeoutMs || DEFAULT_MOBILE_BUILD_TIMEOUT_MS,
631
+ });
632
+ try {
633
+ await runner.run(
634
+ `docker run ${platformFlag}--name ${shellQuote(containerName)} --rm -v "$PWD":/workspace -w /workspace -e PUB_CACHE=/workspace/.pub-cache -e GRADLE_USER_HOME=${shellQuote(gradleHome)} ${shellQuote(MOBILE_BUILDER_IMAGE)} sh -lc ${shellQuote(FLUTTER_DOCKER_BUILD_SCRIPT)}`,
635
+ {
636
+ cwd: unitPath,
637
+ dryRun: options.dryRun,
638
+ timeoutMs: options.timeoutMs || DEFAULT_MOBILE_BUILD_TIMEOUT_MS,
639
+ onStdout: options.onLog,
640
+ onStderr: options.onLog,
641
+ },
642
+ );
643
+ } finally {
644
+ await runner.run(`docker rm -f ${shellQuote(containerName)}`, {
645
+ cwd: unitPath,
646
+ dryRun: options.dryRun,
647
+ allowFailure: true,
648
+ timeoutMs: options.timeoutMs || DEFAULT_MOBILE_BUILD_TIMEOUT_MS,
649
+ });
650
+ }
651
+ }
652
+
653
+ function ndkVersionToRelease(version) {
654
+ const parts = version.split('.');
655
+ const major = parseInt(parts[0], 10);
656
+ const minor = parseInt(parts[1] || '0', 10);
657
+ if (minor === 0) return `r${major}`;
658
+ return `r${major}${String.fromCharCode(97 + minor)}`;
659
+ }
660
+
661
+ async function readNdkVersionFromGradle(runner, unitPath, options = {}) {
662
+ for (const rel of ['android/app/build.gradle.kts', 'android/app/build.gradle', 'app/build.gradle.kts', 'app/build.gradle']) {
663
+ const src = await readTextFile(runner, `${unitPath}/${rel}`, options);
664
+ if (!src) continue;
665
+ const m = src.match(/ndkVersion\s*(?:=)?\s*["']([0-9]+\.[0-9]+\.[0-9]+)["']/);
666
+ if (m) return m[1];
667
+ }
668
+ return null;
669
+ }
670
+
671
+ async function ensureNdkOnHost(runner, unitPath, androidSdkCacheDir, flutterSdkCacheDir, options = {}) {
672
+ const ndkVersion = await readNdkVersionFromGradle(runner, unitPath, options);
673
+ if (!ndkVersion) {
674
+ const result = await runner.run(
675
+ `grep -RhoE 'ndkVersion[^0-9]*[0-9]+\\.[0-9]+\\.[0-9]+' ${shellQuote(`${unitPath}/${flutterSdkCacheDir}/packages/flutter_tools`)} 2>/dev/null | grep -oE '[0-9]+\\.[0-9]+\\.[0-9]+' | head -n 1`,
676
+ { cwd: unitPath, allowFailure: true },
677
+ );
678
+ const fromFlutter = String(result.stdout || '').trim();
679
+ if (!fromFlutter) return;
680
+ await ensureNdkOnHost(runner, unitPath, androidSdkCacheDir, flutterSdkCacheDir, { ...options, _resolvedNdkVersion: fromFlutter });
681
+ return;
682
+ }
683
+
684
+ const resolvedVersion = options._resolvedNdkVersion || ndkVersion;
685
+ const ndkPropertiesPath = `${unitPath}/${androidSdkCacheDir}/ndk/${resolvedVersion}/source.properties`;
686
+ const hasNdk = await runner.exists(ndkPropertiesPath);
687
+ if (hasNdk) return;
688
+
689
+ const release = ndkVersionToRelease(resolvedVersion);
690
+ const ndkUrl = `https://dl.google.com/android/repository/android-ndk-${release}-linux.zip`;
691
+ const zipPath = `${unitPath}/${androidSdkCacheDir}/ndk-downloads/ndk-${resolvedVersion}.zip`;
692
+ const ndkDir = `${unitPath}/${androidSdkCacheDir}/ndk/${resolvedVersion}`;
693
+
694
+ if (options.onLog) {
695
+ await options.onLog(`[mobile] Pre-downloading Android NDK ${resolvedVersion} on host (native, no QEMU)`);
696
+ }
697
+
698
+ await runner.run(
699
+ `curl -C - -fL --retry 3 --retry-delay 30 --connect-timeout 120 -o ${shellQuote(zipPath)} ${shellQuote(ndkUrl)}`,
700
+ {
701
+ cwd: unitPath,
702
+ dryRun: options.dryRun,
703
+ allowFailure: true,
704
+ timeoutMs: options.timeoutMs || DEFAULT_MOBILE_BUILD_TIMEOUT_MS,
705
+ onStdout: options.onLog,
706
+ onStderr: options.onLog,
707
+ },
708
+ );
709
+
710
+ await runner.run(
711
+ `if [ -f ${shellQuote(zipPath)} ]; then` +
712
+ ` tmp=$(mktemp -d);` +
713
+ ` if unzip -q ${shellQuote(zipPath)} -d "$tmp" 2>/dev/null; then` +
714
+ ` src=$(find "$tmp" -maxdepth 1 -name 'android-ndk-*' -type d | head -n 1);` +
715
+ ` if [ -n "$src" ] && [ -f "$src/source.properties" ]; then` +
716
+ ` mkdir -p ${shellQuote(ndkDir)} && cp -a "$src/." ${shellQuote(ndkDir)}/ && rm -f ${shellQuote(zipPath)};` +
717
+ ` else rm -rf ${shellQuote(ndkDir)} ${shellQuote(zipPath)}; fi;` +
718
+ ` else rm -f ${shellQuote(zipPath)}; fi;` +
719
+ ` rm -rf "$tmp"; fi`,
720
+ { cwd: unitPath, dryRun: options.dryRun, allowFailure: true, timeoutMs: 30 * 60 * 1000 },
721
+ );
722
+ }
723
+
724
+ async function ensureAndroidSdkCacheDirs(runner, unitPath, androidSdkCacheDir, aptCacheDir, options = {}) {
725
+ const cacheRoot = `${unitPath}/${androidSdkCacheDir}`;
726
+ const aptRoot = `${unitPath}/${aptCacheDir}`;
727
+ await runner.run(
728
+ `mkdir -p ${shellQuote(`${cacheRoot}/licenses`)} ${shellQuote(`${cacheRoot}/ndk`)} ${shellQuote(`${cacheRoot}/ndk-downloads`)} ${shellQuote(`${cacheRoot}/platforms`)} ${shellQuote(`${cacheRoot}/build-tools`)} ${shellQuote(`${cacheRoot}/cmdline-tools`)} ${shellQuote(`${cacheRoot}/platform-tools`)} ${shellQuote(`${cacheRoot}/cmake`)} ${shellQuote(`${aptRoot}/archives`)} ${shellQuote(`${aptRoot}/lists`)}`,
729
+ {
730
+ cwd: unitPath,
731
+ dryRun: options.dryRun,
732
+ timeoutMs: options.timeoutMs || DEFAULT_MOBILE_BUILD_TIMEOUT_MS,
733
+ },
734
+ );
735
+ }
736
+
737
+ async function runFlutterAndroidSdkDockerBuild(
738
+ runner,
739
+ unitPath,
740
+ options = {},
741
+ platform,
742
+ ) {
743
+ const hostArchitecture = await detectHostArchitecture(runner, unitPath, options);
744
+ const isArm64 = hostArchitecture === 'arm64';
745
+ const builderImage = isArm64 ? MOBILE_ANDROID_SDK_ARM64_BUILDER_IMAGE : MOBILE_ANDROID_SDK_BUILDER_IMAGE;
746
+ const platformFlag = platform ? `--platform ${platform} ` : '';
747
+ const platformLabel = String(platform || (isArm64 ? 'arm64' : 'native')).replace(/[^a-z0-9]+/gi, '-');
748
+ const containerName = `k3s-mobile-android-sdk-${hashText(`${unitPath}-${platformLabel}`).slice(0, 12)}`;
749
+ const gradleHome = `/workspace/.gradle/mobile-android-sdk-${platformLabel}`;
750
+ const flutterSdkCacheDir = `.k3s-deployer/flutter-sdk-${platformLabel}`;
751
+ const androidSdkCacheDir = `.k3s-deployer/android-sdk-${platformLabel}`;
752
+ const aptCacheDir = `.k3s-deployer/apt-cache-${platformLabel}`;
753
+ await ensureAndroidSdkCacheDirs(runner, unitPath, androidSdkCacheDir, aptCacheDir, options);
754
+ await ensureNdkOnHost(runner, unitPath, androidSdkCacheDir, flutterSdkCacheDir, options);
755
+ await runner.run(`docker rm -f ${shellQuote(containerName)}`, {
756
+ cwd: unitPath,
757
+ dryRun: options.dryRun,
758
+ allowFailure: true,
759
+ timeoutMs: options.timeoutMs || DEFAULT_MOBILE_BUILD_TIMEOUT_MS,
760
+ });
761
+ try {
762
+ await runner.run(
763
+ `docker run ${platformFlag}--name ${shellQuote(containerName)} --rm -v "$PWD":/workspace -v "$PWD/${flutterSdkCacheDir}:/opt/flutter" -v "$PWD/${androidSdkCacheDir}/licenses:/opt/android-sdk-linux/licenses" -v "$PWD/${androidSdkCacheDir}/ndk:/opt/android-sdk-linux/ndk" -v "$PWD/${androidSdkCacheDir}/ndk-downloads:/opt/android-sdk-linux/ndk-downloads" -v "$PWD/${androidSdkCacheDir}/platforms:/opt/android-sdk-linux/platforms" -v "$PWD/${androidSdkCacheDir}/build-tools:/opt/android-sdk-linux/build-tools" -v "$PWD/${androidSdkCacheDir}/cmdline-tools:/opt/android-sdk-linux/cmdline-tools" -v "$PWD/${androidSdkCacheDir}/platform-tools:/opt/android-sdk-linux/platform-tools" -v "$PWD/${androidSdkCacheDir}/cmake:/opt/android-sdk-linux/cmake" -v "$PWD/${aptCacheDir}/archives:/var/cache/apt/archives" -v "$PWD/${aptCacheDir}/lists:/var/lib/apt/lists" -w /workspace -e PUB_CACHE=/workspace/.pub-cache -e GRADLE_USER_HOME=${shellQuote(gradleHome)} -e JAVA_TOOL_OPTIONS="-Djdk.tls.client.protocols=TLSv1.2 -Dhttps.protocols=TLSv1.2" ${shellQuote(builderImage)} sh -lc ${shellQuote(FLUTTER_ANDROID_SDK_BUILD_SCRIPT)}`,
764
+ {
765
+ cwd: unitPath,
766
+ dryRun: options.dryRun,
767
+ timeoutMs: options.timeoutMs || DEFAULT_MOBILE_BUILD_TIMEOUT_MS,
768
+ onStdout: options.onLog,
769
+ onStderr: options.onLog,
770
+ },
771
+ );
772
+ } finally {
773
+ await runner.run(`docker rm -f ${shellQuote(containerName)}`, {
774
+ cwd: unitPath,
775
+ dryRun: options.dryRun,
776
+ allowFailure: true,
777
+ timeoutMs: options.timeoutMs || DEFAULT_MOBILE_BUILD_TIMEOUT_MS,
778
+ });
779
+ }
780
+ }
781
+
782
+ async function runFlutterAndroidSdkDockerBuildWithAmd64Fallback(
783
+ runner,
784
+ unitPath,
785
+ options = {},
786
+ ) {
787
+ try {
788
+ await runFlutterAndroidSdkDockerBuild(
789
+ runner,
790
+ unitPath,
791
+ options,
792
+ 'linux/amd64',
793
+ );
794
+ } catch (androidSdkError) {
795
+ if (!isExecFormatError(androidSdkError)) {
796
+ throw androidSdkError;
797
+ }
798
+
799
+ if (options.onLog) {
800
+ await options.onLog(
801
+ 'Android SDK builder needs amd64 emulation, installing amd64 support in Docker',
802
+ );
803
+ }
804
+
805
+ await ensureDockerAmd64Emulation(runner, unitPath, options);
806
+ await runFlutterAndroidSdkDockerBuild(
807
+ runner,
808
+ unitPath,
809
+ options,
810
+ 'linux/amd64',
811
+ );
812
+ }
813
+ }
814
+
815
+ async function buildFlutterWithAndroidSdkBuilder(runner, unitPath, options = {}) {
816
+ const hostArchitecture = await detectHostArchitecture(runner, unitPath, options);
817
+ if (hostArchitecture === 'arm64') {
818
+ if (options.onLog) {
819
+ await options.onLog(
820
+ 'ARM64 host: running Android SDK builder natively (ARM64 JVM + binfmt for x86 tools)',
821
+ );
822
+ }
823
+ // Ensure binfmt_misc is registered so x86_64 build-tool binaries (aapt2, etc.)
824
+ // can execute transparently without forcing the whole JVM under emulation.
825
+ await ensureDockerAmd64Emulation(runner, unitPath, options);
826
+ try {
827
+ await runFlutterAndroidSdkDockerBuild(runner, unitPath, options);
828
+ } catch (nativeErr) {
829
+ if (isExecFormatError(nativeErr)) {
830
+ // Native arm64 image unavailable — fall back to full amd64 emulation.
831
+ if (options.onLog) {
832
+ await options.onLog(
833
+ 'ARM64 native build failed (exec format), falling back to linux/amd64 emulation',
834
+ );
835
+ }
836
+ await runFlutterAndroidSdkDockerBuild(runner, unitPath, options, 'linux/amd64');
837
+ } else {
838
+ throw nativeErr;
839
+ }
840
+ }
841
+ return;
842
+ }
843
+
844
+ try {
845
+ await runFlutterAndroidSdkDockerBuild(runner, unitPath, options);
846
+ } catch (androidSdkError) {
847
+ if (isExecFormatError(androidSdkError)) {
848
+ if (options.onLog) {
849
+ await options.onLog(
850
+ 'Android SDK builder needs amd64 emulation, installing amd64 support in Docker',
851
+ );
852
+ }
853
+
854
+ await ensureDockerAmd64Emulation(runner, unitPath, options);
855
+ await runFlutterAndroidSdkDockerBuild(runner, unitPath, options);
856
+ return;
857
+ }
858
+
859
+ if (
860
+ !(
861
+ isGenSnapshotArtifactError(androidSdkError) ||
862
+ isAapt2LoaderError(androidSdkError) ||
863
+ isMissingImageManifestError(androidSdkError)
864
+ )
865
+ ) {
866
+ throw androidSdkError;
867
+ }
868
+
869
+ if (options.onLog) {
870
+ await options.onLog(
871
+ 'Native Android SDK builder is incompatible with this host, retrying with linux/amd64 emulation',
872
+ );
873
+ }
874
+ await runFlutterAndroidSdkDockerBuildWithAmd64Fallback(
875
+ runner,
876
+ unitPath,
877
+ options,
878
+ );
879
+ }
880
+ }
881
+
882
+ async function ensureDockerAmd64Emulation(runner, unitPath, options = {}) {
883
+ await runner.run(
884
+ `docker run --privileged --rm tonistiigi/binfmt --install amd64`,
885
+ {
886
+ cwd: unitPath,
887
+ dryRun: options.dryRun,
888
+ timeoutMs: options.timeoutMs || DEFAULT_MOBILE_BUILD_TIMEOUT_MS,
889
+ onStdout: options.onLog,
890
+ onStderr: options.onLog,
891
+ },
892
+ );
893
+ }
894
+
895
+ async function buildFlutterWithDocker(runner, unitPath, options = {}) {
896
+ const hostArchitecture = await detectHostArchitecture(runner, unitPath, options);
897
+ if (hostArchitecture === 'arm64') {
898
+ if (options.onLog) {
899
+ await options.onLog(
900
+ 'ARM64 host detected, skipping the default Flutter builder image and using the Android SDK builder directly',
901
+ );
902
+ }
903
+ await buildFlutterWithAndroidSdkBuilder(runner, unitPath, options);
904
+ return resolveFlutterArtifactPath(runner, unitPath, options);
905
+ }
906
+
907
+ try {
908
+ try {
909
+ await runFlutterDockerBuild(runner, unitPath, options);
910
+ } catch (error) {
911
+ if (!isGenSnapshotArtifactError(error)) {
912
+ throw error;
913
+ }
914
+
915
+ if (options.onLog) {
916
+ await options.onLog(
917
+ 'Containerized Flutter build is missing host gen_snapshot artifacts, retrying with linux/amd64 platform',
918
+ );
919
+ }
920
+
921
+ try {
922
+ await runFlutterDockerBuild(runner, unitPath, options, 'linux/amd64');
923
+ } catch (amd64Error) {
924
+ if (!isExecFormatError(amd64Error)) {
925
+ throw amd64Error;
926
+ }
927
+
928
+ if (options.onLog) {
929
+ await options.onLog(
930
+ 'linux/amd64 Flutter container needs binfmt emulation, installing amd64 support in Docker',
931
+ );
932
+ }
933
+
934
+ await ensureDockerAmd64Emulation(runner, unitPath, options);
935
+ await runFlutterDockerBuild(runner, unitPath, options, 'linux/amd64');
936
+ }
937
+ }
938
+ } catch (error) {
939
+ if (!isAapt2LoaderError(error)) {
940
+ throw error;
941
+ }
942
+
943
+ if (options.onLog) {
944
+ await options.onLog(
945
+ 'Current Flutter builder image is incompatible with Android build tools on this host, retrying with Android SDK builder',
946
+ );
947
+ }
948
+ await buildFlutterWithAndroidSdkBuilder(runner, unitPath, options);
949
+ }
950
+
951
+ return resolveFlutterArtifactPath(runner, unitPath, options);
952
+ }
953
+
954
+ async function buildAndroidWithDocker(runner, unitPath, options = {}) {
955
+ await runner.run(
956
+ `docker run --rm -v "$PWD":/workspace -w /workspace -e GRADLE_USER_HOME=/workspace/.gradle ${shellQuote(MOBILE_BUILDER_IMAGE)} sh -lc 'chmod +x ./gradlew && ./gradlew assembleRelease'`,
957
+ {
958
+ cwd: unitPath,
959
+ dryRun: options.dryRun,
960
+ timeoutMs: options.timeoutMs || DEFAULT_MOBILE_BUILD_TIMEOUT_MS,
961
+ onStdout: options.onLog,
962
+ onStderr: options.onLog,
963
+ },
964
+ );
965
+ }
966
+
967
+ async function buildMobileUnit(plan, unit, runner, workspacePath, options = {}) {
968
+ const unitPath = unit.root === '.' ? workspacePath : `${workspacePath}/${unit.root}`;
969
+ const dryRun = options.dryRun || false;
970
+ const log = options.onLog || (() => {});
971
+ const timeoutMs = options.timeoutMs || DEFAULT_MOBILE_BUILD_TIMEOUT_MS;
972
+ const hostArchitecture = await detectHostArchitecture(runner, unitPath, {
973
+ dryRun,
974
+ timeoutMs,
975
+ });
976
+ if (unit.framework === 'android') {
977
+ await log(`Building Android app from ${unit.root}`);
978
+ try {
979
+ await runner.run('./gradlew assembleRelease', {
980
+ cwd: unitPath,
981
+ dryRun,
982
+ timeoutMs,
983
+ onStdout: log,
984
+ onStderr: log,
985
+ });
986
+ } catch (error) {
987
+ await log('Native Android build failed, retrying with containerized builder');
988
+ await buildAndroidWithDocker(runner, unitPath, options);
989
+ }
990
+ return `${unitPath}/app/build/outputs/apk/release/app-release.apk`;
991
+ }
992
+ await log(`Building Flutter app from ${unit.root}`);
993
+ await ensureFirebaseOptions(runner, unitPath, {
994
+ dryRun,
995
+ onLog: log,
996
+ timeoutMs,
997
+ });
998
+ const applicationId = await findAndroidApplicationId(runner, unitPath, {
999
+ dryRun,
1000
+ timeoutMs,
1001
+ });
1002
+ if (applicationId && plan.mobile) {
1003
+ plan.mobile.applicationId = applicationId;
1004
+ }
1005
+ const gitRevision = await resolveGitRevision(runner, workspacePath, {
1006
+ dryRun,
1007
+ timeoutMs,
1008
+ });
1009
+ const cachedBuild = await readMobileBuildCache(runner, workspacePath, {
1010
+ dryRun,
1011
+ timeoutMs,
1012
+ });
1013
+ if (
1014
+ cachedBuild &&
1015
+ cachedBuild.framework === 'flutter' &&
1016
+ cachedBuild.hostArchitecture === hostArchitecture &&
1017
+ cachedBuild.gitRevision &&
1018
+ gitRevision &&
1019
+ cachedBuild.gitRevision === gitRevision &&
1020
+ (!applicationId || cachedBuild.applicationId === applicationId) &&
1021
+ cachedBuild.artifactPath &&
1022
+ (await runner.exists(cachedBuild.artifactPath))
1023
+ ) {
1024
+ await log(`Reusing cached mobile build from ${cachedBuild.gitRevision.slice(0, 12)}`);
1025
+ return cachedBuild.artifactPath;
1026
+ }
1027
+ const hasFlutter = await commandSucceeds(
1028
+ runner,
1029
+ 'command -v flutter >/dev/null 2>&1',
1030
+ { cwd: unitPath, dryRun },
1031
+ );
1032
+ if (hasFlutter) {
1033
+ await runner.run('flutter pub get', {
1034
+ cwd: unitPath,
1035
+ dryRun,
1036
+ timeoutMs,
1037
+ onStdout: log,
1038
+ onStderr: log,
1039
+ });
1040
+ await runner.run('flutter build apk --release', {
1041
+ cwd: unitPath,
1042
+ dryRun,
1043
+ timeoutMs,
1044
+ onStdout: log,
1045
+ onStderr: log,
1046
+ });
1047
+ return (
1048
+ (await findLatestFlutterArtifact(runner, unitPath, {
1049
+ dryRun,
1050
+ timeoutMs,
1051
+ })) ||
1052
+ `${unitPath}/build/app/outputs/flutter-apk/app-release.apk`
1053
+ );
1054
+ } else {
1055
+ await log('Flutter is not installed on the target host, using containerized builder');
1056
+ const artifactPath = await buildFlutterWithDocker(runner, unitPath, options);
1057
+ await writeMobileBuildCache(runner, workspacePath, {
1058
+ framework: 'flutter',
1059
+ artifactPath,
1060
+ applicationId: applicationId || null,
1061
+ hostArchitecture,
1062
+ gitRevision,
1063
+ updatedAt: new Date().toISOString(),
1064
+ });
1065
+ return artifactPath;
1066
+ }
1067
+ }
1068
+
1069
+ async function startMobilePreview(plan, config = {}, options = {}) {
1070
+ if (!plan.mobile) {
1071
+ throw new Error('No mobile artifact available for this plan');
1072
+ }
1073
+ if (!config.mobilePreviewAdapter) {
1074
+ throw new Error('No mobile preview adapter is configured');
1075
+ }
1076
+ return config.mobilePreviewAdapter.start({
1077
+ projectSlug: plan.inspection.projectSlug,
1078
+ framework: plan.mobile.framework,
1079
+ artifactPath: options.artifactPath,
1080
+ applicationId: plan.mobile.applicationId,
1081
+ runner: options.runner,
1082
+ onLog: options.onLog,
1083
+ workspacePath: options.workspacePath,
1084
+ plan,
1085
+ domain: options.overrides?.domain || null,
1086
+ cloudflareDns: options.cloudflareDns || null,
1087
+ });
1088
+ }
1089
+
1090
+ module.exports = {
1091
+ buildMobileUnit,
1092
+ startMobilePreview,
1093
+ };