feat: add Linux (Ubuntu) GUI installer build script

- Add scripts/build-installer-linux.sh for building the Wails GUI
  installer on Ubuntu (checks for GTK3 and WebKit2GTK dependencies)
- Refactor autoInstallPython3() into per-platform files so each OS
  can auto-install Python 3 (winget on Windows, apt-get on Linux,
  brew on macOS)
- Add installer-linux target to Makefile

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
jim800121chen 2026-03-24 12:12:17 +08:00
parent c558c9e3b8
commit e46d179165
6 changed files with 260 additions and 65 deletions

View File

@ -130,10 +130,16 @@ installer-payload: build-server-tray ## Stage payload files for GUI installer
cp scripts/kneron_detect.py installer/payload/scripts/
cp server/scripts/firmware/KL520/*.bin installer/payload/scripts/firmware/KL520/
cp server/scripts/firmware/KL720/*.bin installer/payload/scripts/firmware/KL720/
@# Copy KneronPLUS wheel if available (for Windows installer)
@# Copy KneronPLUS wheels if available (Windows from local_service_win, macOS from Downloads)
@if ls ../local_service_win/KneronPLUS*.whl 1>/dev/null 2>&1; then \
cp ../local_service_win/KneronPLUS*.whl installer/payload/scripts/; \
echo " KneronPLUS wheel bundled."; \
echo " KneronPLUS Windows wheel bundled."; \
fi
@mkdir -p installer/payload/scripts/macos
@MACOS_WHL="$$(find ~/Downloads/KL520Web/package/macos -name 'KneronPLUS*.whl' 2>/dev/null | head -1)"; \
if [ -n "$$MACOS_WHL" ]; then \
cp "$$MACOS_WHL" installer/payload/scripts/macos/; \
echo " KneronPLUS macOS wheel bundled."; \
fi
@# Copy WinUSB driver files (for Windows installer)
@mkdir -p installer/payload/drivers/amd64
@ -150,6 +156,10 @@ installer: installer-payload ## Build GUI installer app
codesign --force --deep --sign - installer/build/bin/EdgeAI-Installer.app
@echo "Installer built and signed! Check installer/build/"
installer-linux: installer-payload ## Build GUI installer for Linux (Ubuntu)
cd installer && wails build -clean
@echo "Installer built! Check installer/build/bin/EdgeAI-Installer"
installer-dev: installer-payload ## Run GUI installer in dev mode
cd installer && wails dev

View File

@ -399,58 +399,6 @@ func (inst *Installer) extractDir(embedDir, destDir string) error {
})
}
// installPython3Windows attempts to install Python 3 via winget on Windows.
func (inst *Installer) installPython3Windows() error {
if runtime.GOOS != "windows" {
return fmt.Errorf("not windows")
}
// Check if winget is available
if _, err := exec.LookPath("winget"); err != nil {
return fmt.Errorf("winget not found — please install Python 3 manually from https://python.org")
}
inst.emitProgress(ProgressEvent{
Step: "python",
Message: "Installing Python 3 via winget...",
Percent: 73,
})
cmd := exec.Command("winget", "install", "Python.Python.3.12",
"--source", "winget",
"--accept-source-agreements", "--accept-package-agreements", "--silent")
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("winget install Python failed: %s — %w", string(out), err)
}
// winget installs Python to %LOCALAPPDATA%\Programs\Python\Python312\
// We need to add it to PATH for the current process
pythonDir := filepath.Join(os.Getenv("LOCALAPPDATA"), "Programs", "Python", "Python312")
scriptsDir := filepath.Join(pythonDir, "Scripts")
os.Setenv("PATH", pythonDir+";"+scriptsDir+";"+os.Getenv("PATH"))
// Verify it works
if _, err := findPython3(); err != nil {
// Try common alternative paths
for _, ver := range []string{"Python313", "Python311", "Python310"} {
altDir := filepath.Join(os.Getenv("LOCALAPPDATA"), "Programs", "Python", ver)
if _, err := os.Stat(filepath.Join(altDir, "python.exe")); err == nil {
os.Setenv("PATH", altDir+";"+filepath.Join(altDir, "Scripts")+";"+os.Getenv("PATH"))
break
}
}
if _, err := findPython3(); err != nil {
return fmt.Errorf("Python installed but not found in PATH — please restart the installer")
}
}
inst.emitProgress(ProgressEvent{
Step: "python",
Message: "Python 3 installed successfully.",
Percent: 75,
})
return nil
}
// setupPythonVenv creates a Python virtual environment and installs requirements.
func (inst *Installer) setupPythonVenv(installDir string) error {
@ -459,17 +407,12 @@ func (inst *Installer) setupPythonVenv(installDir string) error {
pythonPath, err := findPython3()
if err != nil {
// On Windows, try to install Python automatically
if runtime.GOOS == "windows" {
if installErr := inst.installPython3Windows(); installErr != nil {
return fmt.Errorf("python3 not found and auto-install failed: %w", installErr)
}
pythonPath, err = findPython3()
if err != nil {
return fmt.Errorf("python3 still not found after installation: %w", err)
}
} else {
return fmt.Errorf("python3 not found on PATH: %w", err)
if installErr := inst.autoInstallPython3(); installErr != nil {
return fmt.Errorf("python3 not found and auto-install failed: %w", installErr)
}
pythonPath, err = findPython3()
if err != nil {
return fmt.Errorf("python3 still not found after installation: %w", err)
}
}

View File

@ -48,6 +48,31 @@ func removeSystemLink() {
os.Remove("/usr/local/bin/edge-ai-server")
}
// autoInstallPython3 attempts to install Python 3 via Homebrew on macOS.
func (inst *Installer) autoInstallPython3() error {
if _, err := exec.LookPath("brew"); err != nil {
return fmt.Errorf("Homebrew not found — please install Python 3: brew install python3 (or https://brew.sh)")
}
inst.emitProgress(ProgressEvent{
Step: "python",
Message: "Installing Python 3 via Homebrew...",
Percent: 73,
})
cmd := exec.Command("brew", "install", "python@3.12")
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("brew install python failed: %s — %w", string(out), err)
}
inst.emitProgress(ProgressEvent{
Step: "python",
Message: "Python 3 installed successfully.",
Percent: 75,
})
return nil
}
func installLibusb(installDir string) error {
if _, err := exec.LookPath("brew"); err != nil {
return fmt.Errorf("Homebrew not found. Install from https://brew.sh then retry")
@ -104,6 +129,11 @@ func installAutoRestart(installDir string) error {
` <string>` + binPath + `</string>`,
` <string>--tray</string>`,
` </array>`,
` <key>EnvironmentVariables</key>`,
` <dict>`,
` <key>PATH</key>`,
` <string>/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>`,
` </dict>`,
` <key>WorkingDirectory</key>`,
` <string>` + installDir + `</string>`,
` <key>KeepAlive</key>`,

View File

@ -109,6 +109,33 @@ func installAutoRestart(installDir string) error {
return nil
}
// autoInstallPython3 attempts to install Python 3 + venv via apt-get on Ubuntu/Debian.
func (inst *Installer) autoInstallPython3() error {
if _, err := exec.LookPath("apt-get"); err != nil {
return fmt.Errorf("apt-get not found — please install python3 and python3-venv manually")
}
inst.emitProgress(ProgressEvent{
Step: "python",
Message: "Installing Python 3 via apt-get (may require authentication)...",
Percent: 73,
})
// Install python3, python3-venv, and pip in one command
cmd := exec.Command("pkexec", "apt-get", "install", "-y",
"python3", "python3-venv", "python3-pip")
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("apt-get install python3 failed: %s — %w", string(out), err)
}
inst.emitProgress(ProgressEvent{
Step: "python",
Message: "Python 3 installed successfully.",
Percent: 75,
})
return nil
}
func installFfmpeg(inst *Installer, needFfmpeg, needYtdlp bool) error {
if _, err := exec.LookPath("apt-get"); err != nil {
return fmt.Errorf("package manager not found — please install ffmpeg manually")

View File

@ -70,6 +70,50 @@ func removeSystemLink() {
fmt.Sprintf(`[Environment]::SetEnvironmentVariable("PATH", "%s", "User")`, newPath)).Run()
}
// autoInstallPython3 attempts to install Python 3 via winget on Windows.
func (inst *Installer) autoInstallPython3() error {
if _, err := exec.LookPath("winget"); err != nil {
return fmt.Errorf("winget not found — please install Python 3 manually from https://python.org")
}
inst.emitProgress(ProgressEvent{
Step: "python",
Message: "Installing Python 3 via winget...",
Percent: 73,
})
cmd := exec.Command("winget", "install", "Python.Python.3.12",
"--source", "winget",
"--accept-source-agreements", "--accept-package-agreements", "--silent")
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("winget install Python failed: %s — %w", string(out), err)
}
// winget installs Python to %LOCALAPPDATA%\Programs\Python\Python312\
// We need to add it to PATH for the current process
pythonDir := filepath.Join(os.Getenv("LOCALAPPDATA"), "Programs", "Python", "Python312")
scriptsDir := filepath.Join(pythonDir, "Scripts")
os.Setenv("PATH", pythonDir+";"+scriptsDir+";"+os.Getenv("PATH"))
// Verify it works — try common alternative version paths
if _, err := findPython3(); err != nil {
for _, ver := range []string{"Python313", "Python311", "Python310"} {
altDir := filepath.Join(os.Getenv("LOCALAPPDATA"), "Programs", "Python", ver)
if _, err := os.Stat(filepath.Join(altDir, "python.exe")); err == nil {
os.Setenv("PATH", altDir+";"+filepath.Join(altDir, "Scripts")+";"+os.Getenv("PATH"))
break
}
}
}
inst.emitProgress(ProgressEvent{
Step: "python",
Message: "Python 3 installed successfully.",
Percent: 75,
})
return nil
}
func installLibusb(installDir string) error {
// 1. Extract libusb-1.0.dll from payload
dllDest := filepath.Join(installDir, "libusb-1.0.dll")

View File

@ -0,0 +1,141 @@
#!/usr/bin/env bash
# Edge AI Platform - Linux (Ubuntu) Installer Build Script
# Usage: bash scripts/build-installer-linux.sh
#
# Prerequisites: Go 1.23+, Node.js 20+, pnpm, Wails CLI
# sudo apt-get install -y build-essential libgtk-3-dev libwebkit2gtk-4.0-dev
# go install github.com/wailsapp/wails/v2/cmd/wails@latest
#
# Run from edge-ai-platform/ directory
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
BASE_DIR="$(dirname "$SCRIPT_DIR")"
REPO_ROOT="$(dirname "$BASE_DIR")"
cd "$BASE_DIR"
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
CYAN='\033[0;36m'
NC='\033[0m'
info() { echo -e "${GREEN}[INFO]${NC} $*"; }
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
err() { echo -e "${RED}[ERROR]${NC} $*"; exit 1; }
step() { echo -e "\n${CYAN}=== $* ===${NC}\n"; }
echo ""
info "Edge AI Platform - Linux Installer Build"
echo ""
# ── Step 1/5: Check prerequisites ──
step "1/5 Checking prerequisites"
missing=()
command -v go >/dev/null 2>&1 || missing+=("Go (https://go.dev/dl/)")
command -v node >/dev/null 2>&1 || missing+=("Node.js (https://nodejs.org/)")
command -v pnpm >/dev/null 2>&1 || missing+=("pnpm (npm install -g pnpm)")
command -v wails >/dev/null 2>&1 || missing+=("Wails (go install github.com/wailsapp/wails/v2/cmd/wails@latest)")
# Check Wails Linux dependencies
pkg-config --exists gtk+-3.0 2>/dev/null || missing+=("libgtk-3-dev (sudo apt-get install libgtk-3-dev)")
pkg-config --exists webkit2gtk-4.0 2>/dev/null || missing+=("libwebkit2gtk-4.0-dev (sudo apt-get install libwebkit2gtk-4.0-dev)")
if [ ${#missing[@]} -gt 0 ]; then
echo -e "${RED}Missing prerequisites:${NC}"
for m in "${missing[@]}"; do
echo -e " ${RED}- $m${NC}"
done
exit 1
fi
info "Go: $(go version)"
info "Node: $(node --version)"
info "pnpm: $(pnpm --version)"
info "Wails: $(wails version 2>&1 | grep -oP 'v[\d.]+')"
echo ""
# ── Step 2/5: Build frontend ──
step "2/5 Building frontend"
cd frontend
pnpm install --frozen-lockfile
pnpm build
rm -rf ../server/web/out
cp -r out ../server/web/out
cd "$BASE_DIR"
info "Frontend built."
# ── Step 3/5: Build server ──
step "3/5 Building server"
mkdir -p dist
cd server
CGO_ENABLED=1 go build \
-ldflags="-s -w" \
-o ../dist/edge-ai-server \
main.go
cd "$BASE_DIR"
size=$(du -m dist/edge-ai-server | cut -f1)
info "Server built: dist/edge-ai-server (${size} MB)"
# ── Step 4/5: Stage installer payload ──
step "4/5 Staging installer payload"
rm -rf installer/payload
mkdir -p installer/payload/data/nef/kl520
mkdir -p installer/payload/data/nef/kl720
mkdir -p installer/payload/scripts/firmware/KL520
mkdir -p installer/payload/scripts/firmware/KL720
cp dist/edge-ai-server installer/payload/
cp server/data/models.json installer/payload/data/
cp server/data/nef/kl520/*.nef installer/payload/data/nef/kl520/ 2>/dev/null || true
cp server/data/nef/kl720/*.nef installer/payload/data/nef/kl720/ 2>/dev/null || true
cp server/scripts/kneron_bridge.py installer/payload/scripts/
cp server/scripts/requirements.txt installer/payload/scripts/
cp server/scripts/update_kl720_firmware.py installer/payload/scripts/
cp scripts/kneron_detect.py installer/payload/scripts/
cp server/scripts/firmware/KL520/*.bin installer/payload/scripts/firmware/KL520/ 2>/dev/null || true
cp server/scripts/firmware/KL720/*.bin installer/payload/scripts/firmware/KL720/ 2>/dev/null || true
# Copy KneronPLUS wheel if available
kp_wheel=$(find "$REPO_ROOT" -maxdepth 2 -name 'KneronPLUS*linux*.whl' 2>/dev/null | head -1)
if [ -n "$kp_wheel" ]; then
cp "$kp_wheel" installer/payload/scripts/
info "KneronPLUS wheel bundled: $(basename "$kp_wheel")"
else
warn "KneronPLUS wheel not found, skipping."
fi
file_count=$(find installer/payload -type f | wc -l)
info "Payload staged: $file_count files."
# ── Step 5/5: Build Wails installer ──
step "5/5 Building Wails installer"
cd installer
wails build -clean
cd "$BASE_DIR"
output="installer/build/bin/EdgeAI-Installer"
if [ -f "$output" ]; then
size=$(du -m "$output" | cut -f1)
echo ""
echo -e "${CYAN}=== Build complete! ===${NC}"
info "Output: $BASE_DIR/$output (${size} MB)"
echo ""
info "To run the installer:"
info " chmod +x $output && ./$output"
else
err "Build output not found!"
fi