mirror of
https://github.com/mudler/LocalAI.git
synced 2026-07-08 15:28:23 -04:00
The `--generated-content-path` and `--upload-path` defaults were the fixed
shared locations `/tmp/generated/content` and `/tmp/localai/upload`. On any
multi-user host these collide across accounts: macOS routes `/tmp` to the
shared `/private/tmp` for every user, so whichever account starts LocalAI
first creates the parent with 0750 perms and every other account then fails
startup with:
unable to create ImageDir: "mkdir /tmp/generated/content: permission denied"
unable to create UploadDir: "mkdir /tmp/localai/upload: permission denied"
The same happens on Linux once a stale root-owned `/tmp/generated` (e.g. from
a prior `sudo` run) is left behind. This bites the desktop launcher and any
app embedding the raw binary (Wingman, nib-desktop), which start `local-ai
run` with no path flags.
Default both paths under the OS temp dir (`os.TempDir()`, honoring `$TMPDIR`;
already per-user on macOS) namespaced by the current UID
(`TMPDIR/localai-<uid>/...`), so accounts never collide while the paths stay
ephemeral. Wired via new kong vars in main.go so every consumer of the raw
binary inherits the fix. All content subdirs (audio, images) derive from
`GeneratedContentDir`, so they are fixed transitively.
As defense in depth, the launcher also anchors these two paths under its own
per-user data directory (mirroring the #10610 fix for data/config), extracted
into a testable `BuildRunArgs`.
Assisted-by: Claude:claude-opus-4-8 [Claude Code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
936 lines
26 KiB
Go
936 lines
26 KiB
Go
package launcher
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net/url"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"syscall"
|
|
"time"
|
|
|
|
"fyne.io/fyne/v2"
|
|
"fyne.io/fyne/v2/container"
|
|
"fyne.io/fyne/v2/dialog"
|
|
"fyne.io/fyne/v2/widget"
|
|
)
|
|
|
|
// Config represents the launcher configuration
|
|
type Config struct {
|
|
ModelsPath string `json:"models_path"`
|
|
BackendsPath string `json:"backends_path"`
|
|
Address string `json:"address"`
|
|
AutoStart bool `json:"auto_start"`
|
|
StartOnBoot bool `json:"start_on_boot"`
|
|
LogLevel string `json:"log_level"`
|
|
EnvironmentVars map[string]string `json:"environment_vars"`
|
|
ShowWelcome *bool `json:"show_welcome"`
|
|
}
|
|
|
|
// Launcher represents the main launcher application
|
|
type Launcher struct {
|
|
// Core components
|
|
releaseManager *ReleaseManager
|
|
config *Config
|
|
ui *LauncherUI
|
|
systray *SystrayManager
|
|
ctx context.Context
|
|
window fyne.Window
|
|
app fyne.App
|
|
|
|
// Process management
|
|
localaiCmd *exec.Cmd
|
|
isRunning bool
|
|
logBuffer *strings.Builder
|
|
logMutex sync.RWMutex
|
|
statusChannel chan string
|
|
|
|
// Logging
|
|
logFile *os.File
|
|
logPath string
|
|
|
|
// UI state
|
|
lastUpdateCheck time.Time
|
|
}
|
|
|
|
// NewLauncher creates a new launcher instance
|
|
func NewLauncher(ui *LauncherUI, window fyne.Window, app fyne.App) *Launcher {
|
|
return &Launcher{
|
|
releaseManager: NewReleaseManager(),
|
|
config: &Config{},
|
|
logBuffer: &strings.Builder{},
|
|
statusChannel: make(chan string, 100),
|
|
ctx: context.Background(),
|
|
ui: ui,
|
|
window: window,
|
|
app: app,
|
|
}
|
|
}
|
|
|
|
// setupLogging sets up log file for LocalAI process output
|
|
func (l *Launcher) setupLogging() error {
|
|
// Create logs directory in data folder
|
|
dataPath := l.GetDataPath()
|
|
logsDir := filepath.Join(dataPath, "logs")
|
|
if err := os.MkdirAll(logsDir, 0755); err != nil {
|
|
return fmt.Errorf("failed to create logs directory: %w", err)
|
|
}
|
|
|
|
// Create log file with timestamp
|
|
timestamp := time.Now().Format("2006-01-02_15-04-05")
|
|
l.logPath = filepath.Join(logsDir, fmt.Sprintf("localai_%s.log", timestamp))
|
|
|
|
logFile, err := os.Create(l.logPath)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create log file: %w", err)
|
|
}
|
|
|
|
l.logFile = logFile
|
|
return nil
|
|
}
|
|
|
|
// Initialize sets up the launcher
|
|
func (l *Launcher) Initialize() error {
|
|
if l.app == nil {
|
|
return fmt.Errorf("app is nil")
|
|
}
|
|
log.Printf("Initializing launcher...")
|
|
|
|
// Setup logging
|
|
if err := l.setupLogging(); err != nil {
|
|
return fmt.Errorf("failed to setup logging: %w", err)
|
|
}
|
|
|
|
// Load configuration
|
|
log.Printf("Loading configuration...")
|
|
if err := l.loadConfig(); err != nil {
|
|
return fmt.Errorf("failed to load config: %w", err)
|
|
}
|
|
log.Printf("Configuration loaded, current state: ModelsPath=%s, BackendsPath=%s, Address=%s, LogLevel=%s",
|
|
l.config.ModelsPath, l.config.BackendsPath, l.config.Address, l.config.LogLevel)
|
|
|
|
// Clean up any partial downloads
|
|
log.Printf("Cleaning up partial downloads...")
|
|
if err := l.releaseManager.CleanupPartialDownloads(); err != nil {
|
|
log.Printf("Warning: failed to cleanup partial downloads: %v", err)
|
|
}
|
|
|
|
if l.config.StartOnBoot {
|
|
l.StartLocalAI()
|
|
}
|
|
// Set default paths if not configured (only if not already loaded from config)
|
|
if l.config.ModelsPath == "" {
|
|
homeDir, _ := os.UserHomeDir()
|
|
l.config.ModelsPath = filepath.Join(homeDir, ".localai", "models")
|
|
log.Printf("Setting default ModelsPath: %s", l.config.ModelsPath)
|
|
}
|
|
if l.config.BackendsPath == "" {
|
|
homeDir, _ := os.UserHomeDir()
|
|
l.config.BackendsPath = filepath.Join(homeDir, ".localai", "backends")
|
|
log.Printf("Setting default BackendsPath: %s", l.config.BackendsPath)
|
|
}
|
|
if l.config.Address == "" {
|
|
l.config.Address = "127.0.0.1:8080"
|
|
log.Printf("Setting default Address: %s", l.config.Address)
|
|
}
|
|
if l.config.LogLevel == "" {
|
|
l.config.LogLevel = "info"
|
|
log.Printf("Setting default LogLevel: %s", l.config.LogLevel)
|
|
}
|
|
if l.config.EnvironmentVars == nil {
|
|
l.config.EnvironmentVars = make(map[string]string)
|
|
log.Printf("Initializing empty EnvironmentVars map")
|
|
}
|
|
|
|
// Set default welcome window preference
|
|
if l.config.ShowWelcome == nil {
|
|
true := true
|
|
l.config.ShowWelcome = &true
|
|
log.Printf("Setting default ShowWelcome: true")
|
|
}
|
|
|
|
// Create directories
|
|
os.MkdirAll(l.config.ModelsPath, 0755)
|
|
os.MkdirAll(l.config.BackendsPath, 0755)
|
|
|
|
// Save the configuration with default values
|
|
if err := l.saveConfig(); err != nil {
|
|
log.Printf("Warning: failed to save default configuration: %v", err)
|
|
}
|
|
|
|
// System tray is now handled in main.go using Fyne's built-in approach
|
|
|
|
// Check if LocalAI is installed
|
|
if !l.releaseManager.IsLocalAIInstalled() {
|
|
log.Printf("No LocalAI installation found")
|
|
fyne.Do(func() {
|
|
l.updateStatus("No LocalAI installation found")
|
|
if l.ui != nil {
|
|
// Show dialog offering to download LocalAI
|
|
l.showDownloadLocalAIDialog()
|
|
}
|
|
})
|
|
}
|
|
|
|
// Check for updates periodically
|
|
go l.periodicUpdateCheck()
|
|
|
|
return nil
|
|
}
|
|
|
|
// StartLocalAI starts the LocalAI server
|
|
func (l *Launcher) StartLocalAI() error {
|
|
if l.isRunning {
|
|
return fmt.Errorf("LocalAI is already running")
|
|
}
|
|
|
|
// Verify binary integrity before starting
|
|
if err := l.releaseManager.VerifyInstalledBinary(); err != nil {
|
|
// Binary is corrupted, remove it and offer to reinstall
|
|
binaryPath := l.releaseManager.GetBinaryPath()
|
|
if removeErr := os.Remove(binaryPath); removeErr != nil {
|
|
log.Printf("Failed to remove corrupted binary: %v", removeErr)
|
|
}
|
|
return fmt.Errorf("LocalAI binary is corrupted: %v. Please reinstall LocalAI", err)
|
|
}
|
|
|
|
binaryPath := l.releaseManager.GetBinaryPath()
|
|
if _, err := os.Stat(binaryPath); os.IsNotExist(err) {
|
|
return fmt.Errorf("LocalAI binary not found. Please download a release first")
|
|
}
|
|
|
|
// Build command arguments
|
|
args := l.BuildRunArgs()
|
|
|
|
l.localaiCmd = exec.CommandContext(l.ctx, binaryPath, args...)
|
|
|
|
// Apply environment variables
|
|
if len(l.config.EnvironmentVars) > 0 {
|
|
env := os.Environ()
|
|
for key, value := range l.config.EnvironmentVars {
|
|
env = append(env, fmt.Sprintf("%s=%s", key, value))
|
|
}
|
|
l.localaiCmd.Env = env
|
|
}
|
|
|
|
// Setup logging
|
|
stdout, err := l.localaiCmd.StdoutPipe()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create stdout pipe: %w", err)
|
|
}
|
|
|
|
stderr, err := l.localaiCmd.StderrPipe()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create stderr pipe: %w", err)
|
|
}
|
|
|
|
// Start the process
|
|
if err := l.localaiCmd.Start(); err != nil {
|
|
return fmt.Errorf("failed to start LocalAI: %w", err)
|
|
}
|
|
|
|
l.isRunning = true
|
|
|
|
fyne.Do(func() {
|
|
l.updateStatus("LocalAI is starting...")
|
|
l.updateRunningState(true)
|
|
})
|
|
|
|
// Start log monitoring
|
|
go l.monitorLogs(stdout, "STDOUT")
|
|
go l.monitorLogs(stderr, "STDERR")
|
|
|
|
// Monitor process with startup timeout
|
|
go func() {
|
|
// Wait for process to start or fail
|
|
err := l.localaiCmd.Wait()
|
|
l.isRunning = false
|
|
fyne.Do(func() {
|
|
l.updateRunningState(false)
|
|
if err != nil {
|
|
l.updateStatus(fmt.Sprintf("LocalAI stopped with error: %v", err))
|
|
} else {
|
|
l.updateStatus("LocalAI stopped")
|
|
}
|
|
})
|
|
}()
|
|
|
|
// Add startup timeout detection
|
|
go func() {
|
|
time.Sleep(10 * time.Second) // Wait 10 seconds for startup
|
|
if l.isRunning {
|
|
// Check if process is still alive
|
|
if l.localaiCmd.Process != nil {
|
|
if err := l.localaiCmd.Process.Signal(syscall.Signal(0)); err != nil {
|
|
// Process is dead, mark as not running
|
|
l.isRunning = false
|
|
fyne.Do(func() {
|
|
l.updateRunningState(false)
|
|
l.updateStatus("LocalAI failed to start properly")
|
|
})
|
|
}
|
|
}
|
|
}
|
|
}()
|
|
|
|
return nil
|
|
}
|
|
|
|
// StopLocalAI stops the LocalAI server
|
|
func (l *Launcher) StopLocalAI() error {
|
|
if !l.isRunning || l.localaiCmd == nil {
|
|
return fmt.Errorf("LocalAI is not running")
|
|
}
|
|
|
|
// Gracefully terminate the process
|
|
if err := l.localaiCmd.Process.Signal(os.Interrupt); err != nil {
|
|
// If graceful termination fails, force kill
|
|
if killErr := l.localaiCmd.Process.Kill(); killErr != nil {
|
|
return fmt.Errorf("failed to kill LocalAI process: %w", killErr)
|
|
}
|
|
}
|
|
|
|
l.isRunning = false
|
|
fyne.Do(func() {
|
|
l.updateRunningState(false)
|
|
l.updateStatus("LocalAI stopped")
|
|
})
|
|
return nil
|
|
}
|
|
|
|
// IsRunning returns whether LocalAI is currently running
|
|
func (l *Launcher) IsRunning() bool {
|
|
return l.isRunning
|
|
}
|
|
|
|
// Shutdown performs cleanup when the application is closing
|
|
func (l *Launcher) Shutdown() error {
|
|
log.Printf("Launcher shutting down, stopping LocalAI...")
|
|
|
|
// Stop LocalAI if it's running
|
|
if l.isRunning {
|
|
if err := l.StopLocalAI(); err != nil {
|
|
log.Printf("Error stopping LocalAI during shutdown: %v", err)
|
|
}
|
|
}
|
|
|
|
// Close log file if open
|
|
if l.logFile != nil {
|
|
if err := l.logFile.Close(); err != nil {
|
|
log.Printf("Error closing log file: %v", err)
|
|
}
|
|
l.logFile = nil
|
|
}
|
|
|
|
log.Printf("Launcher shutdown complete")
|
|
return nil
|
|
}
|
|
|
|
// GetLogs returns the current log buffer
|
|
func (l *Launcher) GetLogs() string {
|
|
l.logMutex.RLock()
|
|
defer l.logMutex.RUnlock()
|
|
return l.logBuffer.String()
|
|
}
|
|
|
|
// GetRecentLogs returns the most recent logs (last 50 lines) for better error display
|
|
func (l *Launcher) GetRecentLogs() string {
|
|
l.logMutex.RLock()
|
|
defer l.logMutex.RUnlock()
|
|
|
|
content := l.logBuffer.String()
|
|
lines := strings.Split(content, "\n")
|
|
|
|
// Get last 50 lines
|
|
if len(lines) > 50 {
|
|
lines = lines[len(lines)-50:]
|
|
}
|
|
|
|
return strings.Join(lines, "\n")
|
|
}
|
|
|
|
// GetConfig returns the current configuration
|
|
func (l *Launcher) GetConfig() *Config {
|
|
return l.config
|
|
}
|
|
|
|
// SetConfig updates the configuration
|
|
func (l *Launcher) SetConfig(config *Config) error {
|
|
l.config = config
|
|
return l.saveConfig()
|
|
}
|
|
|
|
func (l *Launcher) GetUI() *LauncherUI {
|
|
return l.ui
|
|
}
|
|
|
|
func (l *Launcher) SetSystray(systray *SystrayManager) {
|
|
l.systray = systray
|
|
}
|
|
|
|
// GetReleaseManager returns the release manager
|
|
func (l *Launcher) GetReleaseManager() *ReleaseManager {
|
|
return l.releaseManager
|
|
}
|
|
|
|
// GetWebUIURL returns the URL for the WebUI
|
|
func (l *Launcher) GetWebUIURL() string {
|
|
address := l.config.Address
|
|
if strings.HasPrefix(address, ":") {
|
|
address = "localhost" + address
|
|
}
|
|
if !strings.HasPrefix(address, "http") {
|
|
address = "http://" + address
|
|
}
|
|
return address
|
|
}
|
|
|
|
// BuildRunArgs assembles the argument list passed to `local-ai run`.
|
|
//
|
|
// Storage paths are anchored to the launcher's data directory instead of the
|
|
// server's own defaults. The server resolves data/config to ${basepath}
|
|
// (the launcher process CWD, often the user's home root) and generated-content
|
|
// /uploads to shared /tmp paths. On a shared /tmp (macOS routes /tmp to
|
|
// /private/tmp for every user) the first account to run LocalAI creates
|
|
// /tmp/generated with 0750 perms, so any other account then fails startup with
|
|
// "mkdir /tmp/generated/content: permission denied". Keeping every writable
|
|
// path under the per-user data directory avoids both the misplacement (#10610)
|
|
// and the cross-user /tmp collision.
|
|
func (l *Launcher) BuildRunArgs() []string {
|
|
dataPath := l.GetDataPath()
|
|
return []string{
|
|
"run",
|
|
"--models-path", l.config.ModelsPath,
|
|
"--backends-path", l.config.BackendsPath,
|
|
"--address", l.config.Address,
|
|
"--log-level", l.config.LogLevel,
|
|
"--data-path", filepath.Join(dataPath, "data"),
|
|
"--localai-config-dir", filepath.Join(dataPath, "configuration"),
|
|
"--generated-content-path", filepath.Join(dataPath, "generated"),
|
|
"--upload-path", filepath.Join(dataPath, "uploads"),
|
|
}
|
|
}
|
|
|
|
// GetDataPath returns the path where LocalAI data and logs are stored
|
|
func (l *Launcher) GetDataPath() string {
|
|
// LocalAI typically stores data in the current working directory or a models directory
|
|
// First check if models path is configured
|
|
if l.config != nil && l.config.ModelsPath != "" {
|
|
// Return the parent directory of models path
|
|
return filepath.Dir(l.config.ModelsPath)
|
|
}
|
|
|
|
// Fallback to home directory LocalAI folder
|
|
homeDir, err := os.UserHomeDir()
|
|
if err != nil {
|
|
return "."
|
|
}
|
|
return filepath.Join(homeDir, ".localai")
|
|
}
|
|
|
|
// CheckForUpdates checks if there are any available updates
|
|
func (l *Launcher) CheckForUpdates() (bool, string, error) {
|
|
log.Printf("CheckForUpdates: checking for available updates...")
|
|
available, version, err := l.releaseManager.IsUpdateAvailable()
|
|
if err != nil {
|
|
log.Printf("CheckForUpdates: error occurred: %v", err)
|
|
return false, "", err
|
|
}
|
|
log.Printf("CheckForUpdates: result - available=%v, version=%s", available, version)
|
|
l.lastUpdateCheck = time.Now()
|
|
return available, version, nil
|
|
}
|
|
|
|
// DownloadUpdate downloads the latest version
|
|
func (l *Launcher) DownloadUpdate(version string, progressCallback func(downloaded, total int64)) error {
|
|
return l.releaseManager.DownloadRelease(version, progressCallback)
|
|
}
|
|
|
|
// GetCurrentVersion returns the current installed version
|
|
func (l *Launcher) GetCurrentVersion() string {
|
|
return l.releaseManager.GetInstalledVersion()
|
|
}
|
|
|
|
// GetCurrentStatus returns the current status
|
|
func (l *Launcher) GetCurrentStatus() string {
|
|
select {
|
|
case status := <-l.statusChannel:
|
|
return status
|
|
default:
|
|
if l.isRunning {
|
|
return "LocalAI is running"
|
|
}
|
|
return "Ready"
|
|
}
|
|
}
|
|
|
|
// GetLastStatus returns the last known status without consuming from channel
|
|
func (l *Launcher) GetLastStatus() string {
|
|
if l.isRunning {
|
|
return "LocalAI is running"
|
|
}
|
|
|
|
// Check if LocalAI is installed
|
|
if !l.releaseManager.IsLocalAIInstalled() {
|
|
return "LocalAI not installed"
|
|
}
|
|
|
|
return "Ready"
|
|
}
|
|
|
|
func (l *Launcher) githubReleaseNotesURL(version string) (*url.URL, error) {
|
|
// Construct GitHub release URL
|
|
releaseURL := fmt.Sprintf("https://github.com/%s/%s/releases/tag/%s",
|
|
l.releaseManager.GitHubOwner,
|
|
l.releaseManager.GitHubRepo,
|
|
version)
|
|
|
|
// Convert string to *url.URL
|
|
return url.Parse(releaseURL)
|
|
}
|
|
|
|
// showDownloadLocalAIDialog shows a dialog offering to download LocalAI
|
|
func (l *Launcher) showDownloadLocalAIDialog() {
|
|
if l.app == nil {
|
|
log.Printf("Cannot show download dialog: app is nil")
|
|
return
|
|
}
|
|
|
|
fyne.DoAndWait(func() {
|
|
// Create a standalone window for the download dialog
|
|
dialogWindow := l.app.NewWindow("LocalAI Installation Required")
|
|
dialogWindow.CenterOnScreen()
|
|
dialogWindow.SetCloseIntercept(func() {
|
|
dialogWindow.Close()
|
|
})
|
|
|
|
// Create the dialog content
|
|
titleLabel := widget.NewLabel("LocalAI Not Found")
|
|
titleLabel.TextStyle = fyne.TextStyle{Bold: true}
|
|
titleLabel.Alignment = fyne.TextAlignCenter
|
|
|
|
messageLabel := widget.NewLabel("LocalAI is not installed on your system.\n\nWould you like to download and install the latest version?")
|
|
messageLabel.Wrapping = fyne.TextWrapWord
|
|
messageLabel.Alignment = fyne.TextAlignCenter
|
|
|
|
// Buttons
|
|
downloadButton := widget.NewButton("Download & Install", func() {
|
|
dialogWindow.Close()
|
|
l.downloadAndInstallLocalAI()
|
|
if l.systray != nil {
|
|
l.systray.recreateMenu()
|
|
}
|
|
})
|
|
downloadButton.Importance = widget.HighImportance
|
|
|
|
// Release notes button
|
|
releaseNotesButton := widget.NewButton("View Release Notes", func() {
|
|
// Get latest release info and open release notes
|
|
go func() {
|
|
release, err := l.releaseManager.GetLatestRelease()
|
|
if err != nil {
|
|
log.Printf("Failed to get latest release info: %v", err)
|
|
return
|
|
}
|
|
|
|
releaseNotesURL, err := l.githubReleaseNotesURL(release.Version)
|
|
if err != nil {
|
|
log.Printf("Failed to parse URL: %v", err)
|
|
return
|
|
}
|
|
|
|
l.app.OpenURL(releaseNotesURL)
|
|
}()
|
|
})
|
|
|
|
skipButton := widget.NewButton("Skip for Now", func() {
|
|
dialogWindow.Close()
|
|
})
|
|
|
|
// Layout - put release notes button above the main action buttons
|
|
actionButtons := container.NewHBox(skipButton, downloadButton)
|
|
content := container.NewVBox(
|
|
titleLabel,
|
|
widget.NewSeparator(),
|
|
messageLabel,
|
|
widget.NewSeparator(),
|
|
releaseNotesButton,
|
|
widget.NewSeparator(),
|
|
actionButtons,
|
|
)
|
|
|
|
dialogWindow.SetContent(content)
|
|
resizeToContent(dialogWindow, content)
|
|
dialogWindow.Show()
|
|
})
|
|
}
|
|
|
|
// downloadAndInstallLocalAI downloads and installs the latest LocalAI version
|
|
func (l *Launcher) downloadAndInstallLocalAI() {
|
|
if l.app == nil {
|
|
log.Printf("Cannot download LocalAI: app is nil")
|
|
return
|
|
}
|
|
|
|
// First check what the latest version is
|
|
go func() {
|
|
log.Printf("Checking for latest LocalAI version...")
|
|
available, version, err := l.CheckForUpdates()
|
|
if err != nil {
|
|
log.Printf("Failed to check for updates: %v", err)
|
|
l.showDownloadError("Failed to check for latest version", err.Error())
|
|
return
|
|
}
|
|
|
|
if !available {
|
|
log.Printf("No updates available, but LocalAI is not installed")
|
|
l.showDownloadError("No Version Available", "Could not determine the latest LocalAI version. Please check your internet connection and try again.")
|
|
return
|
|
}
|
|
|
|
log.Printf("Latest version available: %s", version)
|
|
// Show progress window with the specific version
|
|
l.showDownloadProgress(version, fmt.Sprintf("Downloading LocalAI %s...", version))
|
|
}()
|
|
}
|
|
|
|
// showDownloadError shows an error dialog for download failures
|
|
func (l *Launcher) showDownloadError(title, message string) {
|
|
fyne.DoAndWait(func() {
|
|
// Create error window
|
|
errorWindow := l.app.NewWindow("Download Error")
|
|
errorWindow.Resize(fyne.NewSize(400, 200))
|
|
errorWindow.CenterOnScreen()
|
|
errorWindow.SetCloseIntercept(func() {
|
|
errorWindow.Close()
|
|
})
|
|
|
|
// Error content
|
|
titleLabel := widget.NewLabel(title)
|
|
titleLabel.TextStyle = fyne.TextStyle{Bold: true}
|
|
titleLabel.Alignment = fyne.TextAlignCenter
|
|
|
|
messageLabel := widget.NewLabel(message)
|
|
messageLabel.Wrapping = fyne.TextWrapWord
|
|
messageLabel.Alignment = fyne.TextAlignCenter
|
|
|
|
// Close button
|
|
closeButton := widget.NewButton("Close", func() {
|
|
errorWindow.Close()
|
|
})
|
|
|
|
// Layout
|
|
content := container.NewVBox(
|
|
titleLabel,
|
|
widget.NewSeparator(),
|
|
messageLabel,
|
|
widget.NewSeparator(),
|
|
closeButton,
|
|
)
|
|
|
|
errorWindow.SetContent(content)
|
|
errorWindow.Show()
|
|
})
|
|
}
|
|
|
|
// showDownloadProgress shows a standalone progress window for downloading LocalAI
|
|
// after a fresh install (no LocalAI binary present yet).
|
|
func (l *Launcher) showDownloadProgress(version, title string) {
|
|
l.showDownloadProgressWindow(version, title, func(win fyne.Window) {
|
|
dialog.ShowConfirm("Installation Complete",
|
|
"LocalAI has been downloaded and installed successfully. You can now start LocalAI from the launcher.",
|
|
func(bool) {
|
|
win.Close()
|
|
l.updateStatus("LocalAI installed successfully")
|
|
if l.systray != nil {
|
|
l.systray.recreateMenu()
|
|
}
|
|
}, win)
|
|
})
|
|
}
|
|
|
|
// showDownloadProgressWindow renders the download progress popup shared by every
|
|
// "download/upgrade LocalAI" entry point. It owns the progress bar, the
|
|
// human-readable byte readout, resume-aware retry, and content-fit window
|
|
// sizing so the behaviour stays identical everywhere. onSuccess runs (on the UI
|
|
// goroutine) once the download verifies, and is responsible for the success
|
|
// dialog and any follow-up; the window is passed in so it can be parented/closed.
|
|
func (l *Launcher) showDownloadProgressWindow(version, title string, onSuccess func(win fyne.Window)) {
|
|
fyne.DoAndWait(func() {
|
|
progressWindow := l.app.NewWindow("Downloading LocalAI")
|
|
progressWindow.CenterOnScreen()
|
|
progressWindow.SetCloseIntercept(func() {
|
|
progressWindow.Close()
|
|
})
|
|
|
|
progressBar := widget.NewProgressBar()
|
|
progressBar.SetValue(0)
|
|
|
|
// Status label. Truncate with an ellipsis so a long "Download failed:
|
|
// <url>" message can't stretch the window (and progress bar) to fit the
|
|
// whole error on one line.
|
|
statusLabel := widget.NewLabel("Preparing download...")
|
|
statusLabel.Truncation = fyne.TextTruncateEllipsis
|
|
|
|
releaseNotesButton := widget.NewButton("View Release Notes", func() {
|
|
releaseNotesURL, err := l.githubReleaseNotesURL(version)
|
|
if err != nil {
|
|
log.Printf("Failed to parse URL: %v", err)
|
|
return
|
|
}
|
|
l.app.OpenURL(releaseNotesURL)
|
|
})
|
|
|
|
// Retry button: hidden until a download fails. GitHub downloads are
|
|
// flaky, and the underlying download resumes from the partial file, so
|
|
// a retry continues where it left off rather than starting over.
|
|
retryButton := widget.NewButton("Retry", nil)
|
|
retryButton.Importance = widget.HighImportance
|
|
retryButton.Hide()
|
|
|
|
buttonRow := container.NewHBox(releaseNotesButton, retryButton)
|
|
content := container.NewVBox(
|
|
widget.NewLabel(title),
|
|
progressBar,
|
|
statusLabel,
|
|
widget.NewSeparator(),
|
|
buttonRow,
|
|
)
|
|
progressWindow.SetContent(content)
|
|
resizeToContent(progressWindow, content)
|
|
|
|
var startDownload func()
|
|
startDownload = func() {
|
|
retryButton.Hide()
|
|
progressBar.SetValue(0)
|
|
statusLabel.SetText("Preparing download...")
|
|
resizeToContent(progressWindow, content)
|
|
|
|
go func() {
|
|
err := l.DownloadUpdate(version, func(downloaded, total int64) {
|
|
fyne.Do(func() {
|
|
if total > 0 {
|
|
progressBar.SetValue(float64(downloaded) / float64(total))
|
|
statusLabel.SetText(fmt.Sprintf("Downloading… %s / %s", formatBytes(downloaded), formatBytes(total)))
|
|
} else {
|
|
statusLabel.SetText(fmt.Sprintf("Downloading… %s", formatBytes(downloaded)))
|
|
}
|
|
})
|
|
})
|
|
|
|
fyne.Do(func() {
|
|
if err != nil {
|
|
statusLabel.SetText(fmt.Sprintf("Download failed: %v", err))
|
|
retryButton.Show()
|
|
resizeToContent(progressWindow, content)
|
|
return
|
|
}
|
|
progressBar.SetValue(1.0)
|
|
statusLabel.SetText("Download complete")
|
|
onSuccess(progressWindow)
|
|
})
|
|
}()
|
|
}
|
|
retryButton.OnTapped = startDownload
|
|
|
|
progressWindow.Show()
|
|
startDownload()
|
|
})
|
|
}
|
|
|
|
// resizeToContent sizes a window to fit its content (with a sane minimum width)
|
|
// so the dialog doesn't show a large blank gap below the last widget.
|
|
func resizeToContent(w fyne.Window, content fyne.CanvasObject) {
|
|
size := content.MinSize()
|
|
if size.Width < 400 {
|
|
size.Width = 400
|
|
}
|
|
w.Resize(size)
|
|
}
|
|
|
|
// formatBytes renders a byte count as a human-readable size (e.g. "12.3 MB").
|
|
func formatBytes(b int64) string {
|
|
const unit = 1024
|
|
if b < unit {
|
|
return fmt.Sprintf("%d B", b)
|
|
}
|
|
div, exp := int64(unit), 0
|
|
for n := b / unit; n >= unit; n /= unit {
|
|
div *= unit
|
|
exp++
|
|
}
|
|
return fmt.Sprintf("%.1f %cB", float64(b)/float64(div), "KMGTPE"[exp])
|
|
}
|
|
|
|
// monitorLogs monitors the output of LocalAI and adds it to the log buffer
|
|
func (l *Launcher) monitorLogs(reader io.Reader, prefix string) {
|
|
scanner := bufio.NewScanner(reader)
|
|
for scanner.Scan() {
|
|
line := scanner.Text()
|
|
timestamp := time.Now().Format("15:04:05")
|
|
logLine := fmt.Sprintf("[%s] %s: %s\n", timestamp, prefix, line)
|
|
|
|
l.logMutex.Lock()
|
|
l.logBuffer.WriteString(logLine)
|
|
// Keep log buffer size reasonable
|
|
if l.logBuffer.Len() > 100000 { // 100KB
|
|
content := l.logBuffer.String()
|
|
// Keep last 50KB
|
|
if len(content) > 50000 {
|
|
l.logBuffer.Reset()
|
|
l.logBuffer.WriteString(content[len(content)-50000:])
|
|
}
|
|
}
|
|
l.logMutex.Unlock()
|
|
|
|
// Write to log file if available
|
|
if l.logFile != nil {
|
|
if _, err := l.logFile.WriteString(logLine); err != nil {
|
|
log.Printf("Failed to write to log file: %v", err)
|
|
}
|
|
}
|
|
|
|
fyne.Do(func() {
|
|
// Notify UI of new log content
|
|
if l.ui != nil {
|
|
l.ui.OnLogUpdate(logLine)
|
|
}
|
|
|
|
// Check for startup completion
|
|
if strings.Contains(line, "API server listening") {
|
|
l.updateStatus("LocalAI is running")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// updateStatus updates the status and notifies UI
|
|
func (l *Launcher) updateStatus(status string) {
|
|
select {
|
|
case l.statusChannel <- status:
|
|
default:
|
|
// Channel full, skip
|
|
}
|
|
|
|
if l.ui != nil {
|
|
l.ui.UpdateStatus(status)
|
|
}
|
|
|
|
if l.systray != nil {
|
|
l.systray.UpdateStatus(status)
|
|
}
|
|
}
|
|
|
|
// updateRunningState updates the running state in UI and systray
|
|
func (l *Launcher) updateRunningState(isRunning bool) {
|
|
if l.ui != nil {
|
|
l.ui.UpdateRunningState(isRunning)
|
|
}
|
|
|
|
if l.systray != nil {
|
|
l.systray.UpdateRunningState(isRunning)
|
|
}
|
|
}
|
|
|
|
// periodicUpdateCheck checks for updates periodically
|
|
func (l *Launcher) periodicUpdateCheck() {
|
|
ticker := time.NewTicker(1 * time.Hour)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-ticker.C:
|
|
available, version, err := l.CheckForUpdates()
|
|
if err == nil && available {
|
|
fyne.Do(func() {
|
|
l.updateStatus(fmt.Sprintf("Update available: %s", version))
|
|
if l.systray != nil {
|
|
l.systray.NotifyUpdateAvailable(version)
|
|
}
|
|
if l.ui != nil {
|
|
l.ui.NotifyUpdateAvailable(version)
|
|
}
|
|
})
|
|
}
|
|
case <-l.ctx.Done():
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// loadConfig loads configuration from file
|
|
func (l *Launcher) loadConfig() error {
|
|
homeDir, err := os.UserHomeDir()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get home directory: %w", err)
|
|
}
|
|
|
|
configPath := filepath.Join(homeDir, ".localai", "launcher.json")
|
|
log.Printf("Loading config from: %s", configPath)
|
|
|
|
if _, err := os.Stat(configPath); os.IsNotExist(err) {
|
|
log.Printf("Config file not found, creating default config")
|
|
// Create default config
|
|
return l.saveConfig()
|
|
}
|
|
|
|
// Load existing config
|
|
configData, err := os.ReadFile(configPath)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to read config file: %w", err)
|
|
}
|
|
|
|
log.Printf("Config file content: %s", string(configData))
|
|
|
|
log.Printf("loadConfig: about to unmarshal JSON data")
|
|
if err := json.Unmarshal(configData, l.config); err != nil {
|
|
return fmt.Errorf("failed to parse config file: %w", err)
|
|
}
|
|
log.Printf("loadConfig: JSON unmarshaled successfully")
|
|
|
|
log.Printf("Loaded config: ModelsPath=%s, BackendsPath=%s, Address=%s, LogLevel=%s",
|
|
l.config.ModelsPath, l.config.BackendsPath, l.config.Address, l.config.LogLevel)
|
|
log.Printf("Environment vars: %v", l.config.EnvironmentVars)
|
|
|
|
return nil
|
|
}
|
|
|
|
// saveConfig saves configuration to file
|
|
func (l *Launcher) saveConfig() error {
|
|
homeDir, err := os.UserHomeDir()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get home directory: %w", err)
|
|
}
|
|
|
|
configDir := filepath.Join(homeDir, ".localai")
|
|
if err := os.MkdirAll(configDir, 0755); err != nil {
|
|
return fmt.Errorf("failed to create config directory: %w", err)
|
|
}
|
|
|
|
// Marshal config to JSON
|
|
log.Printf("saveConfig: marshaling config with EnvironmentVars: %v", l.config.EnvironmentVars)
|
|
configData, err := json.MarshalIndent(l.config, "", " ")
|
|
if err != nil {
|
|
return fmt.Errorf("failed to marshal config: %w", err)
|
|
}
|
|
log.Printf("saveConfig: JSON marshaled successfully, length: %d", len(configData))
|
|
|
|
configPath := filepath.Join(configDir, "launcher.json")
|
|
log.Printf("Saving config to: %s", configPath)
|
|
log.Printf("Config content: %s", string(configData))
|
|
|
|
if err := os.WriteFile(configPath, configData, 0644); err != nil {
|
|
return fmt.Errorf("failed to write config file: %w", err)
|
|
}
|
|
|
|
log.Printf("Config saved successfully")
|
|
return nil
|
|
}
|