此文總結了我如何將 flutter 使用 AppImage 打包發佈的經過,雖然是針對的 flutter 程序,但它應該也能適用於其它有類似問題的程序打包
打包工具選擇
前段時間本喵開發了一個 android 視頻播放器,因爲是使用 flutter 開發的所以打算移植到 linux 使用。爲了簡單不想以系統包管理器分發(太多不同發行平臺了,即使本喵本地也因爲新老設備而存在多個不同版本的 ubuntu, 它們軟件包依賴並不兼容),換以跨多種發佈平臺的打包方式來分發,於是面臨了三個目前主流的選擇:
- appimage
- flatpak
- snap
考慮到 snap 和 flatpak 都以沙箱模式運行,調用顯卡解碼視頻可能會比較麻煩,於是果斷選擇了 appimage
AppImage 官方方案
首先直接使用 AppImage 官方提供的方案 進行了打包,很扯,操作很麻煩下載了一堆東西然後最後失敗了
因爲它沒有打包 glibc,所以當我在一個和編譯環境 glibc 版本不兼容的系統上運行是,直接報錯了程序無法啓動,控制檯打印了缺少某些 glibc 庫檔案後就退出了,大概就是類似下面這樣的錯誤信息
./XXX: /lib/x86_64-linux-gnu/libc.so.6: version `GLIBC_2.34' not found (required by ./XXX)
glibc
既然是 glibc 的問題那簡單,於是我直接寫了個腳本,使用 ldd 遞歸去把所有依賴的動態庫一起打包不就好了。當然既然是所有也自然包含了 glibc
當完成後發現沒有任何錯誤打印,程序直接崩潰了。通過和 gemini 的探討,發現應該是 ld-linux.so 的問題,linux 使用 ld-linux.so 來加載動態庫(對於 x64 系統來說它通常是 /lib/x86_64-linux-gnu/ld-linux-x86-64.so.2)
ld-linux.so
互不兼容的 ld-linux.so 無法正確加載不兼容的 動態庫,那太簡單了,把 ld-linux.so 也打包就好了,但是動態庫搜索路徑可以通過 LD_LIBRARY_PATH 環境變量來設置,但 gemini 卻告訴本喵無法指定使用特定 ld-linux.so 來加載動態庫
於是還是 google 後,在 github 上看到類似需求,他她它們解決方法是直接使用 ld-linux.so 去執行程序
/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2 your_commnad
原來不需要指定可以直接使用它來執行,那馬上試試看,結果 flutter 返回了下述錯誤
embedder.cc (1613): 'FlutterEngineCreateAOTData' returned 'kInvalidArguments'. Invalid ELF path specified.
雖然窗口啓動了,但初始化失敗一直就一個黑色窗口沒什麼用。不過本喵測試了下打包 bash 是可以正常運行的,應該只是flutter 不能使用 ld-linux.so 去直接執行。其它程序要打包 glibc 不妨先試下直接打包 ld-linux.so 並使用 ld-linux.so 執行看看能否成功
patchelf
gemini 還是無能爲力可能本喵問題沒問對吧。於是再次google 發現了,原來 elf 檔案(linux 二進制執行檔案) 會指定 ld-linux.so 的路徑,編譯時指定就好了,另外 patchelf 這個程序可以修補已經編譯好的 elf 的 ld-linux.so 加載路徑,嘗試了下果然運行成功了
那問題就解決了
- 首先打包所有依賴
- 使用 patchelf 修補 flutter elf 檔案去加載我們準備好的 ld-linux.so
- AppRun 將打包的 ld-linux.so 釋放到 /tmp/ 下面去
AppImage 運行時的路徑是隨機的然而 patchelf 修補的 ld-linux.so 路徑必須是絕對路徑,故步驟3中只能將其釋放到 /tmp/ 下,選擇 tmp 是因爲這個路徑所有人都有讀寫權限
另外爲了優化,應該在 /tmp 下以 777 權限創建一個檔案夾來存儲不同版本的 ld-linux.so 並且只在不存在時釋放 ld-linux.so。這樣多個使用此方案打包的程序就可以共用相同兼容版本的 ld-linux.so 檔案
自動化腳本
所有工作流程清楚後,寫個bash自動腳本就完工了,請參考:
#!/usr/bin/env bash
#Program:
# flutter 自動打包到 AppImage
#History:
# 2025-02-07
#Email:
# [email protected]
#
# 請將下述變量修改爲你的真實環境
# appimagetool 工具路徑,請從此處下載 https://github.com/AppImage/AppImageKit/releases/latest
Appimagetool="appimagetool-x86_64"
# patchelf 工具路徑,請從此處下載 https://github.com/NixOS/patchelf/releases/latest
Patchelf="patchelf"
# 要創建的 AppImage 檔案夾位置
AppDir="bin/myapp"
# 要寫入的 AppImage 應用名稱
AppName="com.king011.myapp"
# 桌面圖片,會自動拷貝到 "$AppDir/myapp.png"
AppLogo="assets/logo.png"
# 要執行的進程名稱(flutter 打包輸出的可執行程式)
AppExec="myapp"
# flutter 產出的軟件包
# 如果是目錄,會自動複製到 "$AppDir/usr/bin" 下面,如果是壓縮包(僅支持常見的 tar 系列)會自動解壓
AppSource="bin/myapp.tar.gz"
# 要輸出的 AppImage 名稱
AppOutput="bin/myapp.AppImage"
# 要打包的動態庫連接程序,通常不需要填寫,腳本會嘗試自動獲取
# /lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
DynamicLinkerPath=""
# 動態庫連接程序版號,通常不需要填寫,腳本會嘗試自動獲取
# /lib/x86_64-linux-gnu/ld-linux-x86-64.so.2 --version
DynamicLinkerVersion=""
# 要將連接器釋放的檔案夾路徑,默認爲 /tmp/ld-linux.so.hook.d
DynamicLinkerDir=""
set -e
BashDir=$(cd "$(dirname $BASH_SOURCE)" && pwd)
if [[ "$Command" == "" ]];then
Command="$0"
fi
function help(){
echo "use AppImage packaging for linux"
echo
echo "Usage:"
echo " $Command [flags]"
echo
echo "Flags:"
echo " -c, --create create AppImage dir"
}
ARGS=`getopt -o hc --long help,create -n "$Command" -- "$@"`
eval set -- "${ARGS}"
CREATE=0
while true
do
case "$1" in
-h|--help)
help
exit 0
;;
-c|--CREATE)
CREATE=1
shift
;;
--)
shift
break
;;
*)
echo Error: unknown flag "$1" for "$Command"
echo "Run '$Command --help' for usage."
exit 1
;;
esac
done
SO=0
doDeps(){
local str="`ldd "$1" |egrep '\(0x[0-9a-fA-F]+\)'`"
ifs=$IFS
IFS="
"
local strs=($str)
IFS=$ifs
local s
local name
local path
for s in "${strs[@]}";do
name=${s%=>*}
name=${name%(0x*}
name="${name#*[[:space:]]}"
name=${name##*/}
name="${name%*[[:space:]]}"
path=${s##*=>}
path=${path%(0x*}
path="${path#*[[:space:]]}"
path="${path%*[[:space:]]}"
if [[ "$path" =~ ^\/.+ ]];then
if [[ ! -f "$2/$name" ]];then
SO=$((SO+1))
echo " $SO. '$path' => '$2/$name'"
cp "$path" "$2/$name"
doDeps "$path" "$2"
fi
fi
done
}
doCreateDir(){
if [[ -d "$AppDir" ]];then
rm "$AppDir" -rf
fi
mkdir "$AppDir/lib" -p
if [[ -d "$AppSource" ]];then
cp "$AppSource" "${AppDir}/bin" -r
elif [[ -f "$AppSource" ]];then
mkdir "$AppDir/bin" -p
if [[ "$AppSource" = *.tar ]];then
tar -xvf "$AppSource" -C "${AppDir}/bin/"
elif [[ "$AppSource" = *.tgz ]] || [[ "$AppSource" = *.tar.gz ]];then
tar -zxvf "$AppSource" -C "${AppDir}/bin/"
elif [[ "$AppSource" = *.tbz ]] || [[ "$AppSource" = *.tar.bz2 ]];then
tar -jxvf "$AppSource" -C "${AppDir}/bin/"
elif [[ "$AppSource" = *.txz ]] || [[ "$AppSource" = *.tar.xz ]];then
tar -jxvf "$AppSource" -C "${AppDir}/bin/"
else
echo "AppSource format unknow: $AppSource"
exit 1
fi
else
echo "AppSource not found: $AppSource"
exit 1
fi
# 寫入配置
local filepath="$AppDir/myapp.desktop"
echo "[Desktop Entry]" > "$filepath"
echo "Name=$AppName" >> "$filepath"
echo "Exec=$AppExec" >> "$filepath"
echo "Icon=myapp" >> "$filepath"
echo "Type=Application" >> "$filepath"
echo "Categories=Utility" >> "$filepath"
cp "$AppLogo" "$AppDir/myapp.png"
# 拷貝直接依賴
doDeps "$AppDir/bin/$AppExec" "$AppDir/lib"
local ifs=$IFS
IFS="
"
# 拷貝 lib 檔案夾下的間接依賴
local files=(`find "$AppDir/bin/lib" -maxdepth 1 -type f`)
IFS=$ifs
for file in "${files[@]}";do
if [[ -f "$file" ]];then
doDeps "$file" "$AppDir/lib"
fi
done
# 刪除與 bin/lib 中重複的依賴檔案
for file in "${files[@]}";do
name=${file##*/}
if [[ -f "$AppDir/lib/$name" ]];then
echo rm "$AppDir/lib/$name"
rm "$AppDir/lib/$name"
fi
done
# 拷貝動態庫連接程序
if [[ "$DynamicLinkerPath" == "" ]];then
DynamicLinkerPath="/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2"
fi
if [[ ! -f "$DynamicLinkerPath" ]];then
echo "DynamicLinkerPath not found: $DynamicLinkerPath"
exit 1
fi
local name=${DynamicLinkerPath##*/}
if [[ "$name" == "" ]];then
echo "DynamicLinkerPath not found: $DynamicLinkerPath"
exit
fi
echo "DynamicLinkerPath: $DynamicLinkerPath"
if [[ ! -f "$AppDir/lib/$name" ]];then
cp "$DynamicLinkerPath" "$AppDir/lib/"
fi
if [[ "$DynamicLinkerVersion" == "" ]];then
DynamicLinkerVersion=`"$DynamicLinkerPath" --version | egrep version`
DynamicLinkerVersion=${DynamicLinkerVersion##*version }
fi
if [[ "$DynamicLinkerVersion" =~ ^[0-9]+\.[0-9]+\.[0-9]*$ ]];then
echo "DynamicLinkerVersion: $DynamicLinkerVersion"
else
echo "DynamicLinkerVersion not supported: '$DynamicLinkerVersion'"
exit 1
fi
if [[ "$DynamicLinkerDir" == "" ]];then
DynamicLinkerDir="/tmp/ld-linux.so.hook.d"
fi
if [[ "$DynamicLinkerDir" != */ ]];then
DynamicLinkerDir="$DynamicLinkerDir/"
fi
echo "DynamicLinkerDir: $DynamicLinkerDir"
# 修補 flutter 可執行檔案,指定使用專用的動態庫加載器
local ld="${DynamicLinkerDir}ld-linux-x86-64.so.$DynamicLinkerVersion"
echo "'$Patchelf' --set-interpreter '$ld' '$AppDir/bin/$AppExec'"
"$Patchelf" --set-interpreter "$ld" "$AppDir/bin/$AppExec"
# 寫入依賴啓動腳本
filepath="$AppDir/AppRun"
echo '#!/bin/bash' > "$filepath"
echo 'set -e' >> "$filepath"
echo 'if [[ "$APPDIR" == "" ]];then' >> "$filepath"
echo ' APPDIR=$(cd "$(dirname "$BASH_SOURCE")" && pwd)' >> "$filepath"
echo 'fi' >> "$filepath"
echo 'if [[ ! -f "'"$ld"'" ]];then' >> "$filepath"
echo ' mkdir -p "'"$DynamicLinkerDir"'"' >> "$filepath"
echo ' chmod 777 "'"$DynamicLinkerDir"'"' >> "$filepath"
echo ' cp "$APPDIR/lib/'"$name"'" "'"$ld"'"' >> "$filepath"
echo 'fi' >> "$filepath"
echo 'if [[ "$LD_LIBRARY_PATH" == "" ]];then' >> "$filepath"
echo ' export LD_LIBRARY_PATH="$APPDIR/lib"' >> "$filepath"
echo 'else' >> "$filepath"
echo ' export LD_LIBRARY_PATH="$APPDIR/usr/lib:$LD_LIBRARY_PATH"' >> "$filepath"
echo 'fi' >> "$filepath"
echo '"$APPDIR/bin/'"$AppExec"'" "$@"' >> "$filepath"
chmod a+x "$filepath"
echo Appimage dir ready
}
if [[ -d "$AppDir" ]];then
if [[ $CREATE == 1 ]] ;then
doCreateDir
fi
else
doCreateDir
fi
"$Appimagetool" "$AppDir" "$AppOutput"
du "$AppOutput" -h
sha256sum "$AppOutput" > "$AppOutput.sha256.txt"
cat "$AppOutput.sha256.txt"
將腳本最前面定義的變量修改爲你的真實情況,之後執行腳本它就會自動完成打包,它做了如下工作
- 創建一個 AppDir 目錄作爲 AppImage 打包根目錄,同時創建必要的打包檔案
- 把 flutter 編譯產出的可執行檔,資產,動態庫,拷貝到 $AppDir/bin
- 使用 ldd 分析 flutter 產出,將依賴遞歸拷貝到 $AppDir/lib
- 打包 ld-linux.so 到 $AppDir/lib
- 使用 patchelf 修補 flutter 可執行檔案使用的 ld-linux.so 路徑
- 創建 $AppDir/AppRun 的 AppImage 入口腳本。它會負責釋放 ld-linux.so 到系統並最終調用 flutter 生成的桌面程式
- 打包生成 AppImage 檔案