mirror of
https://github.com/mudler/LocalAI.git
synced 2026-07-08 23:37:43 -04:00
* fix(launcher): resume flaky downloads, drop redundant percent, fit dialogs
The binary upgrade/download flow had three rough edges:
- The status label printed "Downloading... N%" right next to a progress
bar already showing the percent. Replace it with a human-readable byte
readout ("Downloading... 12.3 MB / 45.6 MB").
- A failed download (GitHub releases are flaky) had no recourse and always
restarted from byte 0. Stream to "<dest>.part" and resume via a
"Range: bytes=N-" request (handling 206/200/416), renaming to the final
path only after checksum verification; on checksum failure the file is
discarded so the next attempt starts clean. Add a Retry button that
appears on failure and resumes from the partial file.
- Progress/install dialogs were hardcoded to oversized dimensions, leaving
a blank gap below "View Release Notes". Size each window to its content
with a sane minimum width.
Also unify the three near-identical download-progress popups into one
Launcher.showDownloadProgressWindow helper (and delete a dead unused copy
in ui.go) so the behaviour stays consistent across every entry point.
The progress callback now reports (downloaded, total) byte counts instead
of a single fraction. Resume/retry behaviour is covered by httptest-backed
unit tests in release_manager_test.go.
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
* fix(launcher): resolve latest version via redirect to dodge GitHub API 403
On a fresh Linux start with no LocalAI installed, the download failed with
"failed to fetch latest release: status 403". The cause is the unauthenticated
api.github.com rate limit (60 requests/hour, per IP): on shared/NAT/CGNAT/cloud
addresses it is exhausted almost immediately and every request 403s.
Resolve the latest version by following the github.com "releases/latest"
redirect instead, reading the tag from the final ".../releases/tag/<tag>" URL.
That endpoint is not subject to the API rate limit. Only the version is ever
consumed by callers, so the tag is sufficient. The JSON API is kept as a
fallback, now honoring GITHUB_TOKEN and reporting rate-limit 403/429 clearly
instead of an opaque status code.
Covered by an httptest-backed unit test that asserts the redirect path is used.
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
---------
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
466 lines
13 KiB
Go
466 lines
13 KiB
Go
package launcher
|
|
|
|
import (
|
|
"fmt"
|
|
"log"
|
|
"net/url"
|
|
|
|
"fyne.io/fyne/v2"
|
|
"fyne.io/fyne/v2/container"
|
|
"fyne.io/fyne/v2/dialog"
|
|
"fyne.io/fyne/v2/driver/desktop"
|
|
"fyne.io/fyne/v2/widget"
|
|
)
|
|
|
|
// SystrayManager manages the system tray functionality
|
|
type SystrayManager struct {
|
|
launcher *Launcher
|
|
window fyne.Window
|
|
app fyne.App
|
|
desk desktop.App
|
|
|
|
// Menu items that need dynamic updates
|
|
startStopItem *fyne.MenuItem
|
|
hasUpdateAvailable bool
|
|
latestVersion string
|
|
icon *fyne.StaticResource
|
|
}
|
|
|
|
// NewSystrayManager creates a new systray manager
|
|
func NewSystrayManager(launcher *Launcher, window fyne.Window, desktop desktop.App, app fyne.App, icon *fyne.StaticResource) *SystrayManager {
|
|
sm := &SystrayManager{
|
|
launcher: launcher,
|
|
window: window,
|
|
app: app,
|
|
desk: desktop,
|
|
icon: icon,
|
|
}
|
|
sm.setupMenu(desktop)
|
|
return sm
|
|
}
|
|
|
|
// setupMenu sets up the system tray menu
|
|
func (sm *SystrayManager) setupMenu(desk desktop.App) {
|
|
sm.desk = desk
|
|
|
|
// Create the start/stop toggle item
|
|
sm.startStopItem = fyne.NewMenuItem("Start LocalAI", func() {
|
|
sm.toggleLocalAI()
|
|
})
|
|
|
|
desk.SetSystemTrayIcon(sm.icon)
|
|
|
|
// Initialize the menu state using recreateMenu
|
|
sm.recreateMenu()
|
|
}
|
|
|
|
// toggleLocalAI starts or stops LocalAI based on current state
|
|
func (sm *SystrayManager) toggleLocalAI() {
|
|
if sm.launcher.IsRunning() {
|
|
go func() {
|
|
if err := sm.launcher.StopLocalAI(); err != nil {
|
|
log.Printf("Failed to stop LocalAI: %v", err)
|
|
sm.showErrorDialog("Failed to Stop LocalAI", err.Error())
|
|
}
|
|
}()
|
|
} else {
|
|
go func() {
|
|
if err := sm.launcher.StartLocalAI(); err != nil {
|
|
log.Printf("Failed to start LocalAI: %v", err)
|
|
sm.showStartupErrorDialog(err)
|
|
}
|
|
}()
|
|
}
|
|
}
|
|
|
|
// openWebUI opens the LocalAI WebUI in the default browser
|
|
func (sm *SystrayManager) openWebUI() {
|
|
if !sm.launcher.IsRunning() {
|
|
return // LocalAI is not running
|
|
}
|
|
|
|
webURL := sm.launcher.GetWebUIURL()
|
|
if parsedURL, err := url.Parse(webURL); err == nil {
|
|
sm.app.OpenURL(parsedURL)
|
|
}
|
|
}
|
|
|
|
// openDocumentation opens the LocalAI documentation
|
|
func (sm *SystrayManager) openDocumentation() {
|
|
if parsedURL, err := url.Parse("https://localai.io"); err == nil {
|
|
sm.app.OpenURL(parsedURL)
|
|
}
|
|
}
|
|
|
|
// updateStartStopItem updates the start/stop menu item based on current state
|
|
func (sm *SystrayManager) updateStartStopItem() {
|
|
// Since Fyne menu items can't change text dynamically, we recreate the menu
|
|
sm.recreateMenu()
|
|
}
|
|
|
|
// recreateMenu recreates the entire menu with updated state
|
|
func (sm *SystrayManager) recreateMenu() {
|
|
if sm.desk == nil {
|
|
return
|
|
}
|
|
|
|
// Determine the action based on LocalAI installation and running state
|
|
var actionItem *fyne.MenuItem
|
|
if !sm.launcher.GetReleaseManager().IsLocalAIInstalled() {
|
|
// LocalAI not installed - show install option
|
|
actionItem = fyne.NewMenuItem("📥 Install Latest Version", func() {
|
|
sm.launcher.showDownloadLocalAIDialog()
|
|
})
|
|
} else if sm.launcher.IsRunning() {
|
|
// LocalAI is running - show stop option
|
|
actionItem = fyne.NewMenuItem("🛑 Stop LocalAI", func() {
|
|
sm.toggleLocalAI()
|
|
})
|
|
} else {
|
|
// LocalAI is installed but not running - show start option
|
|
actionItem = fyne.NewMenuItem("▶️ Start LocalAI", func() {
|
|
sm.toggleLocalAI()
|
|
})
|
|
}
|
|
|
|
menuItems := []*fyne.MenuItem{}
|
|
|
|
// Add status at the top (clickable for details)
|
|
status := sm.launcher.GetLastStatus()
|
|
statusText := sm.truncateText(status, 30)
|
|
statusItem := fyne.NewMenuItem("📊 Status: "+statusText, func() {
|
|
sm.showStatusDetails(status, "")
|
|
})
|
|
menuItems = append(menuItems, statusItem)
|
|
|
|
// Only show version if LocalAI is installed
|
|
if sm.launcher.GetReleaseManager().IsLocalAIInstalled() {
|
|
version := sm.launcher.GetCurrentVersion()
|
|
versionText := sm.truncateText(version, 25)
|
|
versionItem := fyne.NewMenuItem("🔧 Version: "+versionText, func() {
|
|
sm.showStatusDetails(status, version)
|
|
})
|
|
menuItems = append(menuItems, versionItem)
|
|
}
|
|
|
|
menuItems = append(menuItems, fyne.NewMenuItemSeparator())
|
|
|
|
// Add update notification if available
|
|
if sm.hasUpdateAvailable {
|
|
updateItem := fyne.NewMenuItem("🔔 New version available ("+sm.latestVersion+")", func() {
|
|
sm.downloadUpdate()
|
|
})
|
|
menuItems = append(menuItems, updateItem)
|
|
menuItems = append(menuItems, fyne.NewMenuItemSeparator())
|
|
}
|
|
|
|
// Core actions
|
|
menuItems = append(menuItems,
|
|
actionItem,
|
|
)
|
|
|
|
// Only show WebUI option if LocalAI is installed
|
|
if sm.launcher.GetReleaseManager().IsLocalAIInstalled() && sm.launcher.IsRunning() {
|
|
menuItems = append(menuItems,
|
|
fyne.NewMenuItem("Open WebUI", func() {
|
|
sm.openWebUI()
|
|
}),
|
|
)
|
|
}
|
|
|
|
menuItems = append(menuItems,
|
|
fyne.NewMenuItemSeparator(),
|
|
fyne.NewMenuItem("Check for Updates", func() {
|
|
sm.checkForUpdates()
|
|
}),
|
|
fyne.NewMenuItemSeparator(),
|
|
fyne.NewMenuItem("Settings", func() {
|
|
sm.showSettings()
|
|
}),
|
|
fyne.NewMenuItem("Show Welcome Window", func() {
|
|
sm.showWelcomeWindow()
|
|
}),
|
|
fyne.NewMenuItem("Open Data Folder", func() {
|
|
sm.openDataFolder()
|
|
}),
|
|
fyne.NewMenuItemSeparator(),
|
|
fyne.NewMenuItem("Documentation", func() {
|
|
sm.openDocumentation()
|
|
}),
|
|
fyne.NewMenuItemSeparator(),
|
|
fyne.NewMenuItem("Quit", func() {
|
|
// Perform cleanup before quitting
|
|
if err := sm.launcher.Shutdown(); err != nil {
|
|
log.Printf("Error during shutdown: %v", err)
|
|
}
|
|
sm.app.Quit()
|
|
}),
|
|
)
|
|
|
|
menu := fyne.NewMenu("LocalAI", menuItems...)
|
|
sm.desk.SetSystemTrayMenu(menu)
|
|
}
|
|
|
|
// UpdateRunningState updates the systray based on running state
|
|
func (sm *SystrayManager) UpdateRunningState(isRunning bool) {
|
|
sm.updateStartStopItem()
|
|
}
|
|
|
|
// UpdateStatus updates the systray menu to reflect status changes
|
|
func (sm *SystrayManager) UpdateStatus(status string) {
|
|
sm.recreateMenu()
|
|
}
|
|
|
|
// checkForUpdates checks for available updates
|
|
func (sm *SystrayManager) checkForUpdates() {
|
|
go func() {
|
|
log.Printf("Checking for updates...")
|
|
available, version, err := sm.launcher.CheckForUpdates()
|
|
if err != nil {
|
|
log.Printf("Failed to check for updates: %v", err)
|
|
return
|
|
}
|
|
|
|
log.Printf("Update check result: available=%v, version=%s", available, version)
|
|
if available {
|
|
sm.hasUpdateAvailable = true
|
|
sm.latestVersion = version
|
|
sm.recreateMenu()
|
|
}
|
|
}()
|
|
}
|
|
|
|
// downloadUpdate downloads the latest update
|
|
func (sm *SystrayManager) downloadUpdate() {
|
|
if !sm.hasUpdateAvailable {
|
|
return
|
|
}
|
|
|
|
// Show progress window
|
|
sm.showDownloadProgress(sm.latestVersion)
|
|
}
|
|
|
|
// showSettings shows the settings window
|
|
func (sm *SystrayManager) showSettings() {
|
|
sm.window.Show()
|
|
sm.window.RequestFocus()
|
|
}
|
|
|
|
// showWelcomeWindow shows the welcome window
|
|
func (sm *SystrayManager) showWelcomeWindow() {
|
|
if sm.launcher.GetUI() != nil {
|
|
sm.launcher.GetUI().ShowWelcomeWindow()
|
|
}
|
|
}
|
|
|
|
// openDataFolder opens the data folder in file manager
|
|
func (sm *SystrayManager) openDataFolder() {
|
|
dataPath := sm.launcher.GetDataPath()
|
|
if parsedURL, err := url.Parse("file://" + dataPath); err == nil {
|
|
sm.app.OpenURL(parsedURL)
|
|
}
|
|
}
|
|
|
|
// NotifyUpdateAvailable sets update notification in systray
|
|
func (sm *SystrayManager) NotifyUpdateAvailable(version string) {
|
|
sm.hasUpdateAvailable = true
|
|
sm.latestVersion = version
|
|
sm.recreateMenu()
|
|
}
|
|
|
|
// truncateText truncates text to specified length and adds ellipsis if needed
|
|
func (sm *SystrayManager) truncateText(text string, maxLength int) string {
|
|
if len(text) <= maxLength {
|
|
return text
|
|
}
|
|
return text[:maxLength-3] + "..."
|
|
}
|
|
|
|
// showStatusDetails shows a detailed status window with full information
|
|
func (sm *SystrayManager) showStatusDetails(status, version string) {
|
|
fyne.DoAndWait(func() {
|
|
// Create status details window
|
|
statusWindow := sm.app.NewWindow("LocalAI Status Details")
|
|
statusWindow.Resize(fyne.NewSize(500, 400))
|
|
statusWindow.CenterOnScreen()
|
|
|
|
// Status information
|
|
statusLabel := widget.NewLabel("Current Status:")
|
|
statusValue := widget.NewLabel(status)
|
|
statusValue.Wrapping = fyne.TextWrapWord
|
|
|
|
// Version information (only show if version exists)
|
|
var versionContainer fyne.CanvasObject
|
|
if version != "" {
|
|
versionLabel := widget.NewLabel("Installed Version:")
|
|
versionValue := widget.NewLabel(version)
|
|
versionValue.Wrapping = fyne.TextWrapWord
|
|
versionContainer = container.NewVBox(versionLabel, versionValue)
|
|
}
|
|
|
|
// Running state
|
|
runningLabel := widget.NewLabel("Running State:")
|
|
runningValue := widget.NewLabel("")
|
|
if sm.launcher.IsRunning() {
|
|
runningValue.SetText("🟢 Running")
|
|
} else {
|
|
runningValue.SetText("🔴 Stopped")
|
|
}
|
|
|
|
// WebUI URL
|
|
webuiLabel := widget.NewLabel("WebUI URL:")
|
|
webuiValue := widget.NewLabel(sm.launcher.GetWebUIURL())
|
|
webuiValue.Wrapping = fyne.TextWrapWord
|
|
|
|
// Recent logs (last 20 lines)
|
|
logsLabel := widget.NewLabel("Recent Logs:")
|
|
logsText := widget.NewMultiLineEntry()
|
|
logsText.SetText(sm.launcher.GetRecentLogs())
|
|
logsText.Wrapping = fyne.TextWrapWord
|
|
logsText.Disable() // Make it read-only
|
|
|
|
// Buttons
|
|
closeButton := widget.NewButton("Close", func() {
|
|
statusWindow.Close()
|
|
})
|
|
|
|
refreshButton := widget.NewButton("Refresh", func() {
|
|
// Refresh the status information
|
|
statusValue.SetText(sm.launcher.GetLastStatus())
|
|
|
|
// Note: Version refresh is not implemented for simplicity
|
|
// The version will be updated when the status details window is reopened
|
|
|
|
if sm.launcher.IsRunning() {
|
|
runningValue.SetText("🟢 Running")
|
|
} else {
|
|
runningValue.SetText("🔴 Stopped")
|
|
}
|
|
logsText.SetText(sm.launcher.GetRecentLogs())
|
|
})
|
|
|
|
openWebUIButton := widget.NewButton("Open WebUI", func() {
|
|
sm.openWebUI()
|
|
})
|
|
|
|
// Layout
|
|
buttons := container.NewHBox(closeButton, refreshButton, openWebUIButton)
|
|
|
|
// Build info container dynamically
|
|
infoItems := []fyne.CanvasObject{
|
|
statusLabel, statusValue,
|
|
widget.NewSeparator(),
|
|
}
|
|
|
|
// Add version section if it exists
|
|
if versionContainer != nil {
|
|
infoItems = append(infoItems, versionContainer, widget.NewSeparator())
|
|
}
|
|
|
|
infoItems = append(infoItems,
|
|
runningLabel, runningValue,
|
|
widget.NewSeparator(),
|
|
webuiLabel, webuiValue,
|
|
)
|
|
|
|
infoContainer := container.NewVBox(infoItems...)
|
|
|
|
content := container.NewVBox(
|
|
infoContainer,
|
|
widget.NewSeparator(),
|
|
logsLabel,
|
|
logsText,
|
|
widget.NewSeparator(),
|
|
buttons,
|
|
)
|
|
|
|
statusWindow.SetContent(content)
|
|
statusWindow.Show()
|
|
})
|
|
}
|
|
|
|
// showErrorDialog shows a simple error dialog
|
|
func (sm *SystrayManager) showErrorDialog(title, message string) {
|
|
fyne.DoAndWait(func() {
|
|
dialog.ShowError(fmt.Errorf("%s", message), sm.window)
|
|
})
|
|
}
|
|
|
|
// showStartupErrorDialog shows a detailed error dialog with process logs
|
|
func (sm *SystrayManager) showStartupErrorDialog(err error) {
|
|
fyne.DoAndWait(func() {
|
|
// Get the recent process logs (more useful for debugging)
|
|
logs := sm.launcher.GetRecentLogs()
|
|
|
|
// Create error window
|
|
errorWindow := sm.app.NewWindow("LocalAI Startup Failed")
|
|
errorWindow.Resize(fyne.NewSize(600, 500))
|
|
errorWindow.CenterOnScreen()
|
|
|
|
// Error message
|
|
errorLabel := widget.NewLabel(fmt.Sprintf("Failed to start LocalAI:\n%s", err.Error()))
|
|
errorLabel.Wrapping = fyne.TextWrapWord
|
|
|
|
// Logs display
|
|
logsLabel := widget.NewLabel("Process Logs:")
|
|
logsText := widget.NewMultiLineEntry()
|
|
logsText.SetText(logs)
|
|
logsText.Wrapping = fyne.TextWrapWord
|
|
logsText.Disable() // Make it read-only
|
|
|
|
// Buttons
|
|
closeButton := widget.NewButton("Close", func() {
|
|
errorWindow.Close()
|
|
})
|
|
|
|
retryButton := widget.NewButton("Retry", func() {
|
|
errorWindow.Close()
|
|
// Try to start again
|
|
go func() {
|
|
if retryErr := sm.launcher.StartLocalAI(); retryErr != nil {
|
|
sm.showStartupErrorDialog(retryErr)
|
|
}
|
|
}()
|
|
})
|
|
|
|
openLogsButton := widget.NewButton("Open Logs Folder", func() {
|
|
sm.openDataFolder()
|
|
})
|
|
|
|
// Layout
|
|
buttons := container.NewHBox(closeButton, retryButton, openLogsButton)
|
|
content := container.NewVBox(
|
|
errorLabel,
|
|
widget.NewSeparator(),
|
|
logsLabel,
|
|
logsText,
|
|
widget.NewSeparator(),
|
|
buttons,
|
|
)
|
|
|
|
errorWindow.SetContent(content)
|
|
errorWindow.Show()
|
|
})
|
|
}
|
|
|
|
// showDownloadProgress shows a progress window for downloading updates. The
|
|
// progress UI (byte readout, resume-aware retry, sizing) is shared with the
|
|
// other download entry points via the launcher; only the post-success behaviour
|
|
// (restart prompt + systray refresh) is specific to the update flow.
|
|
func (sm *SystrayManager) showDownloadProgress(version string) {
|
|
sm.launcher.showDownloadProgressWindow(version, fmt.Sprintf("Downloading LocalAI version %s", version), func(win fyne.Window) {
|
|
dialog.ShowConfirm("Update Downloaded",
|
|
"LocalAI has been updated successfully. Please restart the launcher to use the new version.",
|
|
func(restart bool) {
|
|
if restart {
|
|
sm.app.Quit()
|
|
}
|
|
win.Close()
|
|
}, win)
|
|
|
|
sm.hasUpdateAvailable = false
|
|
sm.latestVersion = ""
|
|
sm.recreateMenu()
|
|
})
|
|
}
|