diff --git a/.gitea/workflows/build-installer.yaml b/.gitea/workflows/build-installer.yaml new file mode 100644 index 0000000..23e3e24 --- /dev/null +++ b/.gitea/workflows/build-installer.yaml @@ -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 diff --git a/.gitignore b/.gitignore index 5803839..0f5b1b9 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,7 @@ !/installer/payload/.gitkeep /installer/build/ /installer/frontend/wailsjs/ +/installer/edge-ai-installer # Test coverage coverage.out diff --git a/Makefile b/Makefile index 531d11d..992d257 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/frontend b/frontend index 089f564..9239a97 160000 --- a/frontend +++ b/frontend @@ -1 +1 @@ -Subproject commit 089f5644a73fd5ac2ecf12bc55c2c9a70b93b30f +Subproject commit 9239a97721215a14ecac520c21e64d440dd36d98 diff --git a/installer/app.go b/installer/app.go index ae74314..385dee2 100644 --- a/installer/app.go +++ b/installer/app.go @@ -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) diff --git a/installer/frontend/app.js b/installer/frontend/app.js index f1c701e..e3e7f1e 100644 --- a/installer/frontend/app.js +++ b/installer/frontend/app.js @@ -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'; + } }); }); diff --git a/installer/frontend/index.html b/installer/frontend/index.html index baa9d78..03ecb12 100644 --- a/installer/frontend/index.html +++ b/installer/frontend/index.html @@ -155,12 +155,22 @@
- +
- +
+ + +
+

Auto-generated random token. Both the server and browser use this to authenticate with the relay.

+
+ +
+ + +

The HTTP URL to access the dashboard via relay. Opened after server launch.

@@ -234,6 +244,7 @@
+