feat: installer improvements + CI workflow

- Auto-generate random relay token during installation
- Pre-fill relay URL and dashboard URL with EC2 defaults
- Fix hardware detection duplicate device parsing
- Add Dashboard URL field to relay config step
- Launch Server now auto-opens dashboard URL with token
- Add ad-hoc codesign to Makefile installer target
- Remove binary from git tracking
- Add Gitea Actions CI workflow for macOS + Windows builds

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jim800121chen 2026-03-07 02:12:25 +08:00
parent d8128b6c75
commit f27ed20cbc
7 changed files with 303 additions and 18 deletions

View File

@ -0,0 +1,145 @@
name: Build Installers
on:
push:
tags:
- 'v*'
workflow_dispatch:
jobs:
build-macos:
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.23'
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install pnpm
run: npm install -g pnpm
- name: Install Wails
run: go install github.com/wailsapp/wails/v2/cmd/wails@latest
- name: Install frontend dependencies
run: cd frontend && pnpm install --frozen-lockfile
- name: Build frontend
run: make build-frontend build-embed
- name: Build server (tray-enabled)
run: make build-server-tray
- name: Stage installer payload
run: make installer-payload
- name: Build macOS installer
run: |
cd installer && wails build -clean
codesign --force --deep --sign - build/bin/EdgeAI-Installer.app
- name: Package macOS installer
run: |
cd installer/build/bin
ditto -c -k --sequesterRsrc --keepParent EdgeAI-Installer.app EdgeAI-Installer-macOS.zip
- name: Upload macOS artifact
uses: actions/upload-artifact@v4
with:
name: EdgeAI-Installer-macOS
path: installer/build/bin/EdgeAI-Installer-macOS.zip
build-windows:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.23'
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install pnpm
run: npm install -g pnpm
- name: Install Wails
run: go install github.com/wailsapp/wails/v2/cmd/wails@latest
- name: Install frontend dependencies
run: cd frontend && pnpm install --frozen-lockfile
- name: Build frontend
run: |
cd frontend && pnpm build
xcopy /E /I /Y out ..\server\web\out
- name: Build server (no tray on Windows CI)
run: |
cd server
$env:CGO_ENABLED="0"
go build -tags notray -ldflags="-s -w" -o ..\dist\edge-ai-server.exe main.go
- name: Stage installer payload
run: |
Remove-Item -Recurse -Force installer\payload -ErrorAction SilentlyContinue
New-Item -ItemType Directory -Force -Path installer\payload\data\nef\kl520
New-Item -ItemType Directory -Force -Path installer\payload\data\nef\kl720
New-Item -ItemType Directory -Force -Path installer\payload\scripts\firmware\KL520
New-Item -ItemType Directory -Force -Path installer\payload\scripts\firmware\KL720
Copy-Item dist\edge-ai-server.exe installer\payload\
Copy-Item server\data\models.json installer\payload\data\
Copy-Item server\data\nef\kl520\*.nef installer\payload\data\nef\kl520\
Copy-Item server\data\nef\kl720\*.nef installer\payload\data\nef\kl720\
Copy-Item server\scripts\kneron_bridge.py installer\payload\scripts\
Copy-Item server\scripts\requirements.txt installer\payload\scripts\
Copy-Item server\scripts\update_kl720_firmware.py installer\payload\scripts\
Copy-Item scripts\kneron_detect.py installer\payload\scripts\
Copy-Item server\scripts\firmware\KL520\*.bin installer\payload\scripts\firmware\KL520\
Copy-Item server\scripts\firmware\KL720\*.bin installer\payload\scripts\firmware\KL720\
- name: Build Windows installer
run: |
cd installer
wails build -clean
- name: Package Windows installer
run: |
Compress-Archive -Path installer\build\bin\EdgeAI-Installer.exe -DestinationPath installer\build\bin\EdgeAI-Installer-Windows.zip
- name: Upload Windows artifact
uses: actions/upload-artifact@v4
with:
name: EdgeAI-Installer-Windows
path: installer/build/bin/EdgeAI-Installer-Windows.zip
release:
needs: [build-macos, build-windows]
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/')
steps:
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
path: artifacts
- name: Create Release
uses: softprops/action-gh-release@v2
with:
files: |
artifacts/EdgeAI-Installer-macOS/EdgeAI-Installer-macOS.zip
artifacts/EdgeAI-Installer-Windows/EdgeAI-Installer-Windows.zip
draft: false
prerelease: false
generate_release_notes: true

1
.gitignore vendored
View File

@ -12,6 +12,7 @@
!/installer/payload/.gitkeep
/installer/build/
/installer/frontend/wailsjs/
/installer/edge-ai-installer
# Test coverage
coverage.out

View File

@ -134,7 +134,8 @@ installer-payload: build-server-tray ## Stage payload files for GUI installer
installer: installer-payload ## Build GUI installer app
cd installer && wails build -clean
@echo "Installer built! Check installer/build/"
codesign --force --deep --sign - installer/build/bin/EdgeAI-Installer.app
@echo "Installer built and signed! Check installer/build/"
installer-dev: installer-payload ## Run GUI installer in dev mode
cd installer && wails dev

@ -1 +1 @@
Subproject commit 089f5644a73fd5ac2ecf12bc55c2c9a70b93b30f
Subproject commit 9239a97721215a14ecac520c21e64d440dd36d98

View File

@ -2,7 +2,9 @@ package main
import (
"context"
"crypto/rand"
"embed"
"encoding/hex"
"encoding/json"
"fmt"
"io/fs"
@ -38,6 +40,7 @@ type InstallConfig struct {
InstallLibusb bool `json:"installLibusb"`
RelayURL string `json:"relayURL"`
RelayToken string `json:"relayToken"`
DashboardURL string `json:"dashboardURL"`
ServerPort int `json:"serverPort"`
Language string `json:"language"`
}
@ -413,8 +416,9 @@ func (inst *Installer) stepWriteConfig(config InstallConfig) error {
"host": "127.0.0.1",
},
"relay": map[string]interface{}{
"url": config.RelayURL,
"token": config.RelayToken,
"url": config.RelayURL,
"token": config.RelayToken,
"dashboardURL": config.DashboardURL,
},
"launcher": map[string]interface{}{
"autoStart": true,
@ -468,21 +472,30 @@ func (inst *Installer) DetectHardware() ([]HardwareDevice, error) {
func parseDetectOutput(output string) []HardwareDevice {
var devices []HardwareDevice
lines := strings.Split(output, "\n")
var current *HardwareDevice
for _, line := range lines {
line = strings.TrimSpace(line)
if strings.Contains(line, "KL520") || strings.Contains(line, "KL720") || strings.Contains(line, "KL730") {
dev := HardwareDevice{}
if strings.Contains(line, "KL520") {
dev.Model = "KL520"
} else if strings.Contains(line, "KL720") {
dev.Model = "KL720"
} else if strings.Contains(line, "KL730") {
dev.Model = "KL730"
if strings.HasPrefix(line, "Device #") {
// Start a new device
if current != nil {
devices = append(devices, *current)
}
current = &HardwareDevice{}
} else if current != nil {
if strings.HasPrefix(line, "Model:") {
val := strings.TrimSpace(strings.TrimPrefix(line, "Model:"))
current.Model = strings.TrimPrefix(val, "Kneron ")
current.Product = val
} else if strings.HasPrefix(line, "Serial:") {
current.Serial = strings.TrimSpace(strings.TrimPrefix(line, "Serial:"))
} else if strings.HasPrefix(line, "Bus:") {
current.Port = strings.TrimSpace(line)
}
dev.Product = line
devices = append(devices, dev)
}
}
if current != nil && current.Model != "" {
devices = append(devices, *current)
}
return devices
}
@ -495,7 +508,24 @@ func (inst *Installer) LaunchServer() (string, error) {
}
binPath := filepath.Join(installDir, binName)
cmd := exec.Command(binPath, "--tray")
// Read config to get relay args
args := []string{"--tray"}
cfgPath := filepath.Join(platformConfigDir(), "config.json")
if data, err := os.ReadFile(cfgPath); err == nil {
var cfg map[string]interface{}
if json.Unmarshal(data, &cfg) == nil {
if relay, ok := cfg["relay"].(map[string]interface{}); ok {
if u, ok := relay["url"].(string); ok && u != "" {
args = append(args, "--relay-url", u)
}
if t, ok := relay["token"].(string); ok && t != "" {
args = append(args, "--relay-token", t)
}
}
}
}
cmd := exec.Command(binPath, args...)
cmd.Dir = installDir
if err := cmd.Start(); err != nil {
return "", fmt.Errorf("failed to start launcher: %w", err)
@ -504,6 +534,35 @@ func (inst *Installer) LaunchServer() (string, error) {
return "Launcher started", nil
}
// GetDashboardURL returns the configured dashboard URL from the install config.
func (inst *Installer) GetDashboardURL() string {
cfgPath := filepath.Join(platformConfigDir(), "config.json")
data, err := os.ReadFile(cfgPath)
if err != nil {
return ""
}
var cfg map[string]interface{}
if json.Unmarshal(data, &cfg) != nil {
return ""
}
if relay, ok := cfg["relay"].(map[string]interface{}); ok {
if u, ok := relay["dashboardURL"].(string); ok {
return u
}
}
return ""
}
// GenerateToken generates a random 16-character hex token for relay authentication.
func (inst *Installer) GenerateToken() string {
b := make([]byte, 8)
if _, err := rand.Read(b); err != nil {
// fallback: should never happen
return "abcdef0123456789"
}
return hex.EncodeToString(b)
}
// OpenBrowser opens the given URL in the system default browser.
func (inst *Installer) OpenBrowser(url string) error {
return openBrowser(url)

View File

@ -49,7 +49,11 @@ const i18n = {
'btn.back': 'Back',
'btn.install': 'Install',
'btn.launch': 'Launch Server',
'btn.openDashboard': 'Open Dashboard',
'btn.close': 'Close',
'relay.tokenHint': 'Auto-generated random token. Both the server and browser use this to authenticate with the relay.',
'relay.dashboardUrl': 'Dashboard URL',
'relay.dashboardHint': 'The HTTP URL to access the dashboard via relay. Opened after server launch.',
'existing.detected': 'Existing installation detected',
'existing.desc': 'An existing installation was found. You can uninstall it or install over it.',
'existing.uninstall': 'Uninstall',
@ -113,7 +117,11 @@ const i18n = {
'btn.back': '上一步',
'btn.install': '安裝',
'btn.launch': '啟動伺服器',
'btn.openDashboard': '開啟控制台',
'btn.close': '關閉',
'relay.tokenHint': '自動產生的隨機 Token。伺服器和瀏覽器都透過此 Token 向 Relay 驗證身份。',
'relay.dashboardUrl': 'Dashboard URL',
'relay.dashboardHint': '透過 Relay 存取 Dashboard 的 HTTP URL。啟動伺服器後會自動開啟。',
'existing.detected': '偵測到既有安裝',
'existing.desc': '發現既有安裝。您可以解除安裝或覆蓋安裝。',
'existing.uninstall': '解除安裝',
@ -142,6 +150,7 @@ let installConfig = {
installLibusb: true,
relayURL: '',
relayToken: '',
dashboardURL: '',
serverPort: 3721,
language: 'en',
};
@ -351,11 +360,43 @@ document.getElementById('btn-rescan').addEventListener('click', () => {
document.getElementById('btn-launch').addEventListener('click', async () => {
try {
await window.go.main.Installer.LaunchServer();
// After launching, if dashboard URL is configured, open it automatically
const dashUrl = installConfig.dashboardURL || await window.go.main.Installer.GetDashboardURL();
if (dashUrl) {
// Give the server a moment to start, then open dashboard
setTimeout(async () => {
// Append relay token as query param if available
let url = dashUrl;
if (installConfig.relayToken) {
const sep = url.includes('?') ? '&' : '?';
url = url + sep + 'token=' + encodeURIComponent(installConfig.relayToken);
}
await window.go.main.Installer.OpenBrowser(url);
}, 2000);
} else {
// No relay — open local
setTimeout(async () => {
const port = installConfig.serverPort || 3721;
await window.go.main.Installer.OpenBrowser('http://127.0.0.1:' + port);
}, 2000);
}
} catch (err) {
alert('Failed to launch: ' + err);
}
});
document.getElementById('btn-open-dashboard').addEventListener('click', async () => {
const dashUrl = installConfig.dashboardURL || await window.go.main.Installer.GetDashboardURL();
if (dashUrl) {
let url = dashUrl;
if (installConfig.relayToken) {
const sep = url.includes('?') ? '&' : '?';
url = url + sep + 'token=' + encodeURIComponent(installConfig.relayToken);
}
await window.go.main.Installer.OpenBrowser(url);
}
});
document.getElementById('btn-close').addEventListener('click', () => {
if (window.runtime && window.runtime.Quit) {
window.runtime.Quit();
@ -427,11 +468,22 @@ document.addEventListener('DOMContentLoaded', () => {
});
// Step 2 Install -> Step 3 (Relay Config)
document.getElementById('btn-install').addEventListener('click', () => {
document.getElementById('btn-install').addEventListener('click', async () => {
installConfig.createSymlink = document.getElementById('comp-symlink').checked;
installConfig.installPythonEnv = document.getElementById('comp-python').checked;
installConfig.installLibusb = document.getElementById('comp-libusb').checked;
showStep(3);
// Auto-generate relay token if empty
const tokenInput = document.getElementById('relay-token');
if (!tokenInput.value.trim()) {
try {
const token = await window.go.main.Installer.GenerateToken();
tokenInput.value = token;
} catch (err) {
console.error('GenerateToken failed:', err);
}
}
});
// Step 3 Back -> Step 2
@ -439,10 +491,21 @@ document.addEventListener('DOMContentLoaded', () => {
showStep(2);
});
// Regenerate token button
document.getElementById('btn-regen-token').addEventListener('click', async () => {
try {
const token = await window.go.main.Installer.GenerateToken();
document.getElementById('relay-token').value = token;
} catch (err) {
console.error('GenerateToken failed:', err);
}
});
// Step 3 Next -> collect relay fields -> Step 4 (Progress) -> start install
document.getElementById('btn-next-3').addEventListener('click', () => {
installConfig.relayURL = document.getElementById('relay-url').value.trim();
installConfig.relayToken = document.getElementById('relay-token').value.trim();
installConfig.dashboardURL = document.getElementById('dashboard-url').value.trim();
const portVal = parseInt(document.getElementById('server-port').value, 10);
installConfig.serverPort = (portVal >= 1024 && portVal <= 65535) ? portVal : 3721;
startInstall();
@ -464,5 +527,10 @@ document.addEventListener('DOMContentLoaded', () => {
const luEl = document.getElementById('summary-libusb');
luEl.textContent = installConfig.installLibusb ? t('complete.installed') : t('complete.skipped');
luEl.className = 'info-value ' + (installConfig.installLibusb ? 'status-ok' : 'status-skipped');
// Show "Open Dashboard" button if relay dashboard URL is configured
if (installConfig.dashboardURL) {
document.getElementById('btn-open-dashboard').style.display = 'inline-flex';
}
});
});

View File

@ -155,12 +155,22 @@
<div class="form-group">
<label class="field-label" data-i18n="relay.url">Relay URL</label>
<input type="text" class="input-field" id="relay-url" placeholder="ws://relay.example.com/tunnel/connect">
<input type="text" class="input-field" id="relay-url" value="ws://ec2-13-192-170-7.ap-northeast-1.compute.amazonaws.com/tunnel/connect">
</div>
<div class="form-group">
<label class="field-label" data-i18n="relay.token">Relay Token</label>
<input type="text" class="input-field" id="relay-token" placeholder="your-relay-token">
<div class="path-input-group">
<input type="text" class="input-field" id="relay-token" placeholder="auto-generated" readonly>
<button id="btn-regen-token" class="btn btn-secondary" title="Regenerate">&#x21BB;</button>
</div>
<p class="field-hint" data-i18n="relay.tokenHint">Auto-generated random token. Both the server and browser use this to authenticate with the relay.</p>
</div>
<div class="form-group">
<label class="field-label" data-i18n="relay.dashboardUrl">Dashboard URL</label>
<input type="text" class="input-field" id="dashboard-url" value="http://ec2-13-192-170-7.ap-northeast-1.compute.amazonaws.com">
<p class="field-hint" data-i18n="relay.dashboardHint">The HTTP URL to access the dashboard via relay. Opened after server launch.</p>
</div>
<div class="form-group">
@ -234,6 +244,7 @@
<div class="actions">
<button id="btn-launch" class="btn btn-primary" data-i18n="btn.launch">Launch Server</button>
<button id="btn-open-dashboard" class="btn btn-secondary" style="display:none" data-i18n="btn.openDashboard">Open Dashboard</button>
<button id="btn-close" class="btn btn-ghost" data-i18n="btn.close">Close</button>
</div>
</section>