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.
- package/index.cjs +1 -0
- package/index.d.ts +193 -0
- package/index.mjs +4 -0
- package/package.json +47 -0
- package/src/bootstrap/index.cjs +211 -0
- package/src/detect/index.cjs +461 -0
- package/src/dns/cloudflare.cjs +163 -0
- package/src/execute/index.cjs +386 -0
- package/src/index.cjs +67 -0
- package/src/mobile/index.cjs +1093 -0
- package/src/mobile/web-android-emulator-adapter.cjs +701 -0
- package/src/plan/index.cjs +200 -0
- package/src/runtime/index.cjs +16 -0
- package/src/runtime/local-runner.cjs +69 -0
- package/src/runtime/ssh-runner.cjs +206 -0
- package/src/shared/source.cjs +145 -0
- package/src/shared/utils.cjs +104 -0
- package/src/templates/dockerfile.cjs +114 -0
- package/src/templates/manifests.cjs +291 -0
|
@@ -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
|
+
};
|