使用 AppImage 打包 flutter

如何使用 AppImage 打包 flutter 程序,AppImage 應該如何打包 glibc 和 ld-linux.so

此文總結了我如何將 flutter 使用 AppImage 打包發佈的經過,雖然是針對的 flutter 程序,但它應該也能適用於其它有類似問題的程序打包

打包工具選擇

前段時間本喵開發了一個 android 視頻播放器,因爲是使用 flutter 開發的所以打算移植到 linux 使用。爲了簡單不想以系統包管理器分發(太多不同發行平臺了,即使本喵本地也因爲新老設備而存在多個不同版本的 ubuntu, 它們軟件包依賴並不兼容),換以跨多種發佈平臺的打包方式來分發,於是面臨了三個目前主流的選擇:

  1. appimage
  2. flatpak
  3. 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 加載路徑,嘗試了下果然運行成功了

那問題就解決了

  1. 首先打包所有依賴
  2. 使用 patchelf 修補 flutter elf 檔案去加載我們準備好的 ld-linux.so
  3. 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"

將腳本最前面定義的變量修改爲你的真實情況,之後執行腳本它就會自動完成打包,它做了如下工作

  1. 創建一個 AppDir 目錄作爲 AppImage 打包根目錄,同時創建必要的打包檔案
  2. 把 flutter 編譯產出的可執行檔,資產,動態庫,拷貝到 $AppDir/bin
  3. 使用 ldd 分析 flutter 產出,將依賴遞歸拷貝到 $AppDir/lib
  4. 打包 ld-linux.so 到 $AppDir/lib
  5. 使用 patchelf 修補 flutter 可執行檔案使用的 ld-linux.so 路徑
  6. 創建 $AppDir/AppRun 的 AppImage 入口腳本。它會負責釋放 ld-linux.so 到系統並最終調用 flutter 生成的桌面程式
  7. 打包生成 AppImage 檔案

發佈留言

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *