diff --git a/local-tool/Makefile b/local-tool/Makefile index 2687506..06ca92a 100644 --- a/local-tool/Makefile +++ b/local-tool/Makefile @@ -446,9 +446,19 @@ payload-linux: build-server-linux vendor-python-linux vendor-wheels-linux vendor @cp vendor/ffmpeg/linux/LICENSE.txt payload/linux/bin/ffmpeg-LICENSE.txt 2>/dev/null || true @if [ -d server/data ]; then cp -R server/data/. payload/linux/data/; fi @if [ -d server/scripts ]; then cp -R server/scripts/. payload/linux/scripts/; fi - @cp vendor/python/linux/python.tar.gz payload/linux/python/ 2>/dev/null || echo "!! WARN: python tarball 缺失" + @if [ ! -f vendor/python/linux/python.tar.gz ]; then \ + echo "!! ERROR: vendor/python/linux/python.tar.gz 不存在,vendor-python-linux 應該已先跑過 !!"; \ + exit 1; \ + fi + @cp vendor/python/linux/python.tar.gz payload/linux/python/ @cp vendor/wheels/linux/*.whl payload/linux/wheels/ 2>/dev/null || true - @echo "==> Linux payload 完成:" + @wheel_count=$$(ls -1 payload/linux/wheels/*.whl 2>/dev/null | wc -l); \ + if [ "$$wheel_count" -lt 4 ]; then \ + echo "!! ERROR: payload/linux/wheels 只找到 $$wheel_count 個 wheel(預期至少 4 個:numpy / opencv / pyusb / kp)"; \ + echo " 請檢查 vendor/wheels/linux/ 並重跑 make vendor-wheels-linux"; \ + exit 1; \ + fi + @echo "==> Linux payload 完成:包含 $$(ls payload/linux/wheels/*.whl 2>/dev/null | wc -l) 個 wheel + python tarball" @du -sh payload/linux 2>/dev/null || true @echo "" @echo "payload/linux 結構:" diff --git a/local-tool/installer/linux/build-appimage.sh b/local-tool/installer/linux/build-appimage.sh index 566be86..8e236d6 100755 --- a/local-tool/installer/linux/build-appimage.sh +++ b/local-tool/installer/linux/build-appimage.sh @@ -76,18 +76,54 @@ if [ -f "$PAYLOAD_LINUX/bin/ffmpeg-LICENSE.txt" ]; then cp "$PAYLOAD_LINUX/bin/ffmpeg-LICENSE.txt" "$APPDIR/usr/share/doc/visiona-local/ffmpeg-LICENSE.txt" fi +echo "==> 檢查 Python runtime + wheels(bundled Python 必備,缺就自動補)" +# Python tarball +if [ ! -f "$PAYLOAD_LINUX/python/python.tar.gz" ]; then + echo "⚠️ payload/linux/python/python.tar.gz 不存在,嘗試自動補(make vendor-python-linux + payload-linux)" + (cd "$PROJECT_ROOT" && make vendor-python-linux payload-linux) +fi +if [ ! -f "$PAYLOAD_LINUX/python/python.tar.gz" ]; then + echo "❌ python tarball 仍缺失:$PAYLOAD_LINUX/python/python.tar.gz" + echo " AppImage 必須內嵌 Python runtime(Linux 強制 bundled mode),無此檔無法 build。" + echo " 請手動執行:make vendor-python-linux" + exit 1 +fi + +# Wheels(至少要有 numpy / opencv / pyusb / kp 4 個關鍵 wheel,bridge scan 才能跑) +wheel_count=$(ls -1 "$PAYLOAD_LINUX/wheels/"*.whl 2>/dev/null | wc -l) +if [ "$wheel_count" -lt 4 ]; then + echo "⚠️ payload/linux/wheels 只找到 $wheel_count 個 wheel(預期至少 4),嘗試自動補(make vendor-wheels-linux + payload-linux)" + (cd "$PROJECT_ROOT" && make vendor-wheels-linux payload-linux) + wheel_count=$(ls -1 "$PAYLOAD_LINUX/wheels/"*.whl 2>/dev/null | wc -l) +fi +if [ "$wheel_count" -lt 4 ]; then + echo "❌ wheels 數量仍不足:$PAYLOAD_LINUX/wheels/ 只有 $wheel_count 個 .whl(預期至少 4:numpy / opencv / pyusb / kp)" + echo " 請檢查 vendor/wheels/linux/ 或 visiona-local/wheels/linux/ 是否有 KneronPLUS wheel" + exit 1 +fi + +# 驗證關鍵 wheel 名稱存在(避免只下載到其他 wheel 卻缺 kp 等) +missing=() +for pkg in numpy opencv pyusb kp; do + if ! ls "$PAYLOAD_LINUX/wheels/"*${pkg}*.whl >/dev/null 2>&1; then + missing+=("$pkg") + fi +done +if [ ${#missing[@]} -gt 0 ]; then + echo "❌ 缺少關鍵 wheel:${missing[*]}" + echo " 目前 wheels 目錄內容:" + ls -1 "$PAYLOAD_LINUX/wheels/" 2>/dev/null | sed 's/^/ /' + echo " kp wheel 需手動放到 visiona-local/wheels/linux/(KneronPLUS SDK 非公開套件)" + exit 1 +fi + +echo "==> ✅ python tarball + $wheel_count 個 wheel 齊備" + echo "==> 複製資料、腳本、Python runtime、wheels" [ -d "$PAYLOAD_LINUX/data" ] && cp -R "$PAYLOAD_LINUX/data/." "$APPDIR/usr/lib/visiona-local/data/" || true [ -d "$PAYLOAD_LINUX/scripts" ] && cp -R "$PAYLOAD_LINUX/scripts/." "$APPDIR/usr/lib/visiona-local/scripts/" || true -[ -f "$PAYLOAD_LINUX/python/python.tar.gz" ] && \ - cp "$PAYLOAD_LINUX/python/python.tar.gz" "$APPDIR/usr/lib/visiona-local/python/" || \ - echo "⚠️ python tarball 不存在" - -if ls "$PAYLOAD_LINUX/wheels/"*.whl >/dev/null 2>&1; then - cp "$PAYLOAD_LINUX/wheels/"*.whl "$APPDIR/usr/lib/visiona-local/wheels/" -else - echo "⚠️ wheels 目錄為空" -fi +cp "$PAYLOAD_LINUX/python/python.tar.gz" "$APPDIR/usr/lib/visiona-local/python/" +cp "$PAYLOAD_LINUX/wheels/"*.whl "$APPDIR/usr/lib/visiona-local/wheels/" # 複製 udev rule 到 lib 供 Wails app 首次啟動時使用 if [ -f "$PROJECT_ROOT/installer/linux/99-kneron.rules" ]; then @@ -133,6 +169,10 @@ export LD_LIBRARY_PATH="${HERE}/usr/lib:${LD_LIBRARY_PATH:-}" export VISIONA_BUNDLE_BIN_DIR="${HERE}/usr/bin" export VISIONA_BUNDLE_LIB_DIR="${HERE}/usr/lib/visiona-local" +# Linux AppImage 強制走 bundled Python(內嵌 python-build-standalone + wheels), +# 避免 system Python 缺 numpy / kp / pyusb 導致 bridge scan 失敗。 +export VISIONA_PYTHON_MODE=bundled + exec "${HERE}/usr/bin/visiona-local" "$@" APPRUN chmod +x "$APPDIR/AppRun" diff --git a/local-tool/server/internal/api/handlers/system_handler.go b/local-tool/server/internal/api/handlers/system_handler.go index 1774baa..9a5a339 100644 --- a/local-tool/server/internal/api/handlers/system_handler.go +++ b/local-tool/server/internal/api/handlers/system_handler.go @@ -281,20 +281,22 @@ func (h *SystemHandler) InstallUdevRule(c *gin.Context) { } defer os.Remove(tmpRule) - // 用 pkexec 提權複製(會彈 Linux 圖形化密碼對話框) - cpCmd := exec.Command("pkexec", "cp", tmpRule, "/etc/udev/rules.d/99-kneron.rules") + // 用 pkexec 一次提權執行三個動作(cp + reload-rules + trigger),使用者只要輸入一次密碼。 + // 用 sh -c 包起來而不是呼叫 3 次 pkexec(原本每次都彈密碼框)。 + // tmpRule 路徑和 /etc/udev/rules.d/99-kneron.rules 都是固定值,無 shell injection 風險。 + script := fmt.Sprintf( + "cp %q /etc/udev/rules.d/99-kneron.rules && udevadm control --reload-rules && udevadm trigger", + tmpRule, + ) + cpCmd := exec.Command("pkexec", "sh", "-c", script) if out, err := cpCmd.CombinedOutput(); err != nil { c.JSON(500, gin.H{"success": false, "error": gin.H{ "code": "UDEV_INSTALL_FAILED", - "message": fmt.Sprintf("pkexec cp failed: %v (%s)", err, strings.TrimSpace(string(out))), + "message": fmt.Sprintf("pkexec install failed: %v (%s)", err, strings.TrimSpace(string(out))), }}) return } - // reload udev(每個指令單獨 pkexec,避免 shell injection) - _ = exec.Command("pkexec", "udevadm", "control", "--reload-rules").Run() - _ = exec.Command("pkexec", "udevadm", "trigger").Run() - c.JSON(200, gin.H{"success": true, "data": gin.H{ "message": "udev rule installed. Please unplug and replug your Kneron device.", }}) diff --git a/local-tool/visiona-local/app.go b/local-tool/visiona-local/app.go index c186525..edc8670 100644 --- a/local-tool/visiona-local/app.go +++ b/local-tool/visiona-local/app.go @@ -1016,6 +1016,14 @@ func (a *App) ensureBundledPython() (string, error) { // locateBundledPythonAssets 找 python tarball 與 wheels 目錄。 // 順序:macOS bundle → 開發模式 payload/darwin → 上一層。 func locateBundledPythonAssets() (tarball, wheelsDir string, err error) { + // 0. Linux AppImage:AppRun 已 export VISIONA_BUNDLE_LIB_DIR + if libDir := os.Getenv("VISIONA_BUNDLE_LIB_DIR"); libDir != "" { + t := filepath.Join(libDir, "python", "python.tar.gz") + w := filepath.Join(libDir, "wheels") + if fileExists(t) && dirExists(w) { + return t, w, nil + } + } // 1. macOS .app bundle:Contents/Resources/python + Resources/wheels if exe, e := os.Executable(); e == nil { exeDir := filepath.Dir(exe) @@ -1059,6 +1067,14 @@ func locateBundledPythonAssets() (tarball, wheelsDir string, err error) { // 2. Windows / Linux 打包後:與 Wails 執行檔同目錄下的 bin/ // 3. 開發模式:payload/darwin/bin(相對 cwd) func locateBundleBinDir() (string, error) { + // Linux AppImage:AppRun 已 export VISIONA_BUNDLE_BIN_DIR=/usr/bin + if binDir := os.Getenv("VISIONA_BUNDLE_BIN_DIR"); binDir != "" { + if dirExists(binDir) { + abs, _ := filepath.Abs(binDir) + return abs, nil + } + } + if exe, err := os.Executable(); err == nil { exeDir := filepath.Dir(exe) if runtime.GOOS == "darwin" { @@ -1102,10 +1118,20 @@ func locateBundleBinDir() (string, error) { // locateBundleDataDir 找 installer 打包的 data 目錄(含 models.json + nef/ 預置模型)。 // // 順序: -// 1. macOS .app bundle:Contents/Resources/data -// 2. Windows / Linux 打包後:與 Wails 執行檔同目錄下的 data/ -// 3. 開發模式:/server/data(相對 cwd,或往上一層 / 兩層) +// 1. Linux AppImage:VISIONA_BUNDLE_LIB_DIR/data(AppRun 注入) +// 2. macOS .app bundle:Contents/Resources/data +// 3. Windows / Linux(非 AppImage):與 Wails 執行檔同目錄下的 data/ +// 4. 開發模式:/server/data(相對 cwd,或往上一層 / 兩層) func locateBundleDataDir() (string, error) { + // Linux AppImage:AppRun 已 export VISIONA_BUNDLE_LIB_DIR=/usr/lib/visiona-local + if libDir := os.Getenv("VISIONA_BUNDLE_LIB_DIR"); libDir != "" { + d := filepath.Join(libDir, "data") + if dirExists(d) { + abs, _ := filepath.Abs(d) + return abs, nil + } + } + if exe, err := os.Executable(); err == nil { exeDir := filepath.Dir(exe) if runtime.GOOS == "darwin" {