mirror of
https://github.com/mudler/LocalAI.git
synced 2026-09-13 14:56:11 -04:00
fix(launcher): auto-start the server so launching the app actually serves
Fixes #11673: on macOS the DMG launcher appeared to launch nothing. After installing, the app sat in the menu bar with no window, nothing listening on localhost:8080, and empty log files, because nothing ever started the server unless the unrelated 'start on system boot' option was enabled. - Start the LocalAI server automatically when the launcher opens and right after a fresh install. The new auto_start_server config key defaults to enabled and gets a settings checkbox; the legacy auto_start key was never honored nor exposed, so every existing launcher.json carries an unintentional false and is deliberately left behind. - Fix the welcome window suppressing itself: its 'don't show this again' checkbox was initialized with the inverted value, and SetChecked fired the change callback which persisted ShowWelcome=false on the very first showing. - Surface auto-start failures through the systray startup-error dialog, since there is no visible window during auto-start. - Pass --app-version to fyne package so the app stops reporting itself as version 0.0.0 in the About box. - Document the first-launch flow (menu bar app, auto-start, WebUI URL) in the macOS getting-started page. - Repair two launcher specs that never ran in CI: a *bool matched against BeTrue and a /tmp assertion that trips on Linux where the test tempdir itself lives under /tmp. Assisted-by: Claude Code:claude-fable-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
1 parent
dd4e75983d
commit
7aeb47cbf3
5 files changed
+178
-23
No files matched your search
@@ -34,6 +34,11 @@ TEST_FLAKES?=5
|
||||
RANDOM := $(shell bash -c 'echo $$RANDOM')
|
||||
|
||||
VERSION?=$(shell git describe --always --tags || echo "dev" )
|
||||
# fyne package only accepts numeric x[.y[.z]] app versions, so reduce git
|
||||
# describe output (v4.9.0, v4.9.0-14-gabc1234, or a bare sha on untagged
|
||||
# checkouts) to its numeric core; anything non-numeric falls back to 0.0.0.
|
||||
# Without this the packaged launcher reports itself as version 0.0.0 (#11673).
|
||||
LAUNCHER_APP_VERSION?=$(shell v=$$(echo "$(VERSION)" | sed -E 's/^v//; s/[+-].*$$//'); echo "$$v" | grep -qE '^[0-9]+(\.[0-9]+){0,2}$$' && echo "$$v" || echo "0.0.0")
|
||||
# go tool nm ./local-ai | grep Commit
|
||||
LD_FLAGS?=-s -w
|
||||
override LD_FLAGS += -X "github.com/mudler/LocalAI/internal.Version=$(VERSION)"
|
||||
@@ -1622,7 +1627,7 @@ site-serve: site
|
||||
build-launcher-darwin:
|
||||
rm -rf dist/LocalAI.app cmd/launcher/LocalAI.app
|
||||
mkdir -p dist
|
||||
cd cmd/launcher && go run fyne.io/tools/cmd/fyne@latest package -os darwin -icon ../../core/http/static/logo.png --executable $(LAUNCHER_BINARY_NAME)
|
||||
cd cmd/launcher && go run fyne.io/tools/cmd/fyne@latest package -os darwin -icon ../../core/http/static/logo.png --executable $(LAUNCHER_BINARY_NAME) --app-version $(LAUNCHER_APP_VERSION)
|
||||
mv cmd/launcher/LocalAI.app dist/LocalAI.app
|
||||
bash contrib/macos/sign-and-notarize.sh sign dist/LocalAI.app
|
||||
|
||||
@@ -1649,4 +1654,4 @@ release-launcher-darwin: notarize-launcher-darwin
|
||||
@echo "dist/LocalAI.dmg is ready"
|
||||
|
||||
build-launcher-linux:
|
||||
cd cmd/launcher && go run fyne.io/tools/cmd/fyne@latest package -os linux -icon ../../core/http/static/logo.png --executable $(LAUNCHER_BINARY_NAME)-linux && mv LocalAI.tar.xz ../../$(LAUNCHER_BINARY_NAME)-linux.tar.xz
|
||||
cd cmd/launcher && go run fyne.io/tools/cmd/fyne@latest package -os linux -icon ../../core/http/static/logo.png --executable $(LAUNCHER_BINARY_NAME)-linux --app-version $(LAUNCHER_APP_VERSION) && mv LocalAI.tar.xz ../../$(LAUNCHER_BINARY_NAME)-linux.tar.xz
|
||||
@@ -24,10 +24,17 @@ import (
|
||||
|
||||
// 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"`
|
||||
ModelsPath string `json:"models_path"`
|
||||
BackendsPath string `json:"backends_path"`
|
||||
Address string `json:"address"`
|
||||
// AutoStart controls whether the launcher starts the LocalAI server as
|
||||
// soon as the launcher itself opens (and right after a fresh install).
|
||||
// Unset means enabled: launching the app must yield a serving endpoint,
|
||||
// which is what the quickstart docs promise. The JSON key is deliberately
|
||||
// not the legacy "auto_start": that field was never honored nor exposed
|
||||
// in any UI, so every existing launcher.json carries an unintentional
|
||||
// false that would keep auto-start permanently off (#11673).
|
||||
AutoStart *bool `json:"auto_start_server"`
|
||||
StartOnBoot bool `json:"start_on_boot"`
|
||||
LogLevel string `json:"log_level"`
|
||||
EnvironmentVars map[string]string `json:"environment_vars"`
|
||||
@@ -122,9 +129,6 @@ func (l *Launcher) Initialize() error {
|
||||
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()
|
||||
@@ -156,6 +160,12 @@ func (l *Launcher) Initialize() error {
|
||||
log.Printf("Setting default ShowWelcome: true")
|
||||
}
|
||||
|
||||
if l.config.AutoStart == nil {
|
||||
enabled := true
|
||||
l.config.AutoStart = &enabled
|
||||
log.Printf("Setting default AutoStart: true")
|
||||
}
|
||||
|
||||
// Create directories
|
||||
os.MkdirAll(l.config.ModelsPath, 0755)
|
||||
os.MkdirAll(l.config.BackendsPath, 0755)
|
||||
@@ -177,6 +187,11 @@ func (l *Launcher) Initialize() error {
|
||||
l.showDownloadLocalAIDialog()
|
||||
}
|
||||
})
|
||||
} else if l.ShouldAutoStartServer() {
|
||||
// The launcher is a tray-only app: without this the user launches it,
|
||||
// sees no window and no server, and concludes it does nothing (#11673).
|
||||
log.Printf("Auto-starting LocalAI server")
|
||||
l.autoStartServer()
|
||||
}
|
||||
|
||||
// Check for updates periodically
|
||||
@@ -185,6 +200,35 @@ func (l *Launcher) Initialize() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ShouldAutoStartServer reports whether the launcher should start the server
|
||||
// without user interaction: at launcher startup and right after a fresh
|
||||
// install. Defaults to enabled; StartOnBoot forces a start even when
|
||||
// auto-start was explicitly disabled, preserving its historical behavior.
|
||||
func (l *Launcher) ShouldAutoStartServer() bool {
|
||||
if l.config == nil {
|
||||
return false
|
||||
}
|
||||
if l.config.StartOnBoot {
|
||||
return true
|
||||
}
|
||||
return l.config.AutoStart == nil || *l.config.AutoStart
|
||||
}
|
||||
|
||||
// autoStartServer starts LocalAI in the background and surfaces failures
|
||||
// through the systray error dialog: during an auto-start there is no visible
|
||||
// window for a regular error dialog to attach to.
|
||||
func (l *Launcher) autoStartServer() {
|
||||
go func() {
|
||||
if err := l.StartLocalAI(); err != nil {
|
||||
log.Printf("Failed to auto-start LocalAI: %v", err)
|
||||
l.updateStatus(fmt.Sprintf("Failed to start LocalAI: %v", err))
|
||||
if l.systray != nil {
|
||||
l.systray.showStartupErrorDialog(err)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// StartLocalAI starts the LocalAI server
|
||||
func (l *Launcher) StartLocalAI() error {
|
||||
if l.isRunning {
|
||||
@@ -644,14 +688,22 @@ func (l *Launcher) showDownloadError(title, message string) {
|
||||
// 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.",
|
||||
message := "LocalAI has been downloaded and installed successfully. You can now start LocalAI from the launcher."
|
||||
if l.ShouldAutoStartServer() {
|
||||
message = "LocalAI has been downloaded and installed successfully. It will start now: manage it and open the WebUI from the system tray icon."
|
||||
}
|
||||
dialog.ShowConfirm("Installation Complete", message,
|
||||
func(bool) {
|
||||
win.Close()
|
||||
l.updateStatus("LocalAI installed successfully")
|
||||
if l.systray != nil {
|
||||
l.systray.recreateMenu()
|
||||
}
|
||||
// A fresh install should end with a running server, not with
|
||||
// the user hunting for a start button in the tray (#11673).
|
||||
if l.ShouldAutoStartServer() && !l.isRunning {
|
||||
l.autoStartServer()
|
||||
}
|
||||
}, win)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package launcher_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -55,7 +56,8 @@ var _ = Describe("Launcher", func() {
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
config := launcherInstance.GetConfig()
|
||||
Expect(config.ShowWelcome).To(BeTrue())
|
||||
Expect(config.ShowWelcome).ToNot(BeNil())
|
||||
Expect(*config.ShowWelcome).To(BeTrue())
|
||||
Expect(config.Address).To(Equal("127.0.0.1:8080"))
|
||||
Expect(config.LogLevel).To(Equal("info"))
|
||||
})
|
||||
@@ -177,13 +179,53 @@ var _ = Describe("Launcher", func() {
|
||||
|
||||
assertFlagValue("--generated-content-path", filepath.Join(dataPath, "generated"))
|
||||
assertFlagValue("--upload-path", filepath.Join(dataPath, "uploads"))
|
||||
// The bug was the server resolving these to shared /tmp paths.
|
||||
// The bug was the server resolving these to its shared /tmp
|
||||
// defaults. Only reject those specific paths: on Linux the test's
|
||||
// own temp directory legitimately lives under /tmp.
|
||||
for _, a := range args {
|
||||
Expect(a).ToNot(HavePrefix("/tmp/"), "run args must not reference shared /tmp paths, got %s", a)
|
||||
Expect(a).ToNot(HavePrefix("/tmp/generated"), "run args must not reference the shared /tmp generated-content default, got %s", a)
|
||||
Expect(a).ToNot(HavePrefix("/tmp/upload"), "run args must not reference the shared /tmp upload default, got %s", a)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// Regression for "Mac dmg launcher launches nothing" (issue #11673): the
|
||||
// launcher created empty log files and served nothing because nothing ever
|
||||
// started the server unless the unrelated "start on system boot" option was
|
||||
// enabled. Launching the app must yield a serving endpoint by default.
|
||||
Describe("ShouldAutoStartServer", func() {
|
||||
It("should auto-start by default when nothing is configured", func() {
|
||||
Expect(launcherInstance.ShouldAutoStartServer()).To(BeTrue())
|
||||
})
|
||||
|
||||
It("should respect an explicit opt-out", func() {
|
||||
config := launcherInstance.GetConfig()
|
||||
err := json.Unmarshal([]byte(`{"auto_start_server": false}`), config)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
Expect(launcherInstance.ShouldAutoStartServer()).To(BeFalse())
|
||||
})
|
||||
|
||||
It("should still auto-start when StartOnBoot is set even if auto-start is off", func() {
|
||||
config := launcherInstance.GetConfig()
|
||||
err := json.Unmarshal([]byte(`{"auto_start_server": false, "start_on_boot": true}`), config)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
Expect(launcherInstance.ShouldAutoStartServer()).To(BeTrue())
|
||||
})
|
||||
|
||||
It("should ignore the legacy auto_start key older launchers persisted as false", func() {
|
||||
// Old launchers marshaled the never-honored AutoStart field as
|
||||
// "auto_start": false into every launcher.json. That stale value
|
||||
// carries no user intent and must not disable auto-start.
|
||||
config := launcherInstance.GetConfig()
|
||||
err := json.Unmarshal([]byte(`{"auto_start": false}`), config)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
Expect(launcherInstance.ShouldAutoStartServer()).To(BeTrue())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Logs", func() {
|
||||
It("should return empty logs initially", func() {
|
||||
logs := launcherInstance.GetLogs()
|
||||
@@ -210,13 +252,38 @@ var _ = Describe("Launcher", func() {
|
||||
})
|
||||
})
|
||||
|
||||
// Regression for the welcome window suppressing itself (part of issue
|
||||
// #11673): the "don't show this welcome window again" checkbox was
|
||||
// initialized with the ShowWelcome value itself, so on the very first
|
||||
// showing it came up checked AND its change callback persisted
|
||||
// ShowWelcome=false, hiding the welcome window forever.
|
||||
var _ = Describe("WelcomeDontShowAgainChecked", func() {
|
||||
It("should be unchecked when the welcome window is enabled", func() {
|
||||
show := true
|
||||
config := &launcher.Config{ShowWelcome: &show}
|
||||
Expect(launcher.WelcomeDontShowAgainChecked(config)).To(BeFalse())
|
||||
})
|
||||
|
||||
It("should be checked when the user opted out", func() {
|
||||
show := false
|
||||
config := &launcher.Config{ShowWelcome: &show}
|
||||
Expect(launcher.WelcomeDontShowAgainChecked(config)).To(BeTrue())
|
||||
})
|
||||
|
||||
It("should be unchecked when the preference is unset", func() {
|
||||
Expect(launcher.WelcomeDontShowAgainChecked(&launcher.Config{})).To(BeFalse())
|
||||
Expect(launcher.WelcomeDontShowAgainChecked(nil)).To(BeFalse())
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("Config", func() {
|
||||
It("should have proper JSON tags", func() {
|
||||
autoStart := true
|
||||
config := &launcher.Config{
|
||||
ModelsPath: "/test/models",
|
||||
BackendsPath: "/test/backends",
|
||||
Address: ":8080",
|
||||
AutoStart: true,
|
||||
AutoStart: &autoStart,
|
||||
LogLevel: "info",
|
||||
EnvironmentVars: map[string]string{"TEST": "value"},
|
||||
}
|
||||
@@ -224,7 +291,7 @@ var _ = Describe("Config", func() {
|
||||
Expect(config.ModelsPath).To(Equal("/test/models"))
|
||||
Expect(config.BackendsPath).To(Equal("/test/backends"))
|
||||
Expect(config.Address).To(Equal(":8080"))
|
||||
Expect(config.AutoStart).To(BeTrue())
|
||||
Expect(*config.AutoStart).To(BeTrue())
|
||||
Expect(config.LogLevel).To(Equal("info"))
|
||||
Expect(config.EnvironmentVars).To(HaveKeyWithValue("TEST", "value"))
|
||||
})
|
||||
|
||||
@@ -34,6 +34,7 @@ type LauncherUI struct {
|
||||
backendsPathEntry *widget.Entry
|
||||
addressEntry *widget.Entry
|
||||
logLevelSelect *widget.Select
|
||||
autoStartCheck *widget.Check
|
||||
startOnBootCheck *widget.Check
|
||||
|
||||
// Environment Variables
|
||||
@@ -75,6 +76,7 @@ func NewLauncherUI() *LauncherUI {
|
||||
backendsPathEntry: widget.NewEntry(),
|
||||
addressEntry: widget.NewEntry(),
|
||||
logLevelSelect: widget.NewSelect([]string{"error", "warn", "info", "debug", "trace"}, nil),
|
||||
autoStartCheck: widget.NewCheck("Start LocalAI when the launcher opens", nil),
|
||||
startOnBootCheck: widget.NewCheck("Start LocalAI on system boot", nil),
|
||||
logText: widget.NewMultiLineEntry(),
|
||||
progressBar: widget.NewProgressBar(),
|
||||
@@ -117,6 +119,7 @@ func (ui *LauncherUI) createConfigTab() *fyne.Container {
|
||||
widget.NewLabel("Log Level:"),
|
||||
ui.logLevelSelect,
|
||||
),
|
||||
ui.autoStartCheck,
|
||||
ui.startOnBootCheck,
|
||||
))
|
||||
|
||||
@@ -401,6 +404,8 @@ func (ui *LauncherUI) saveConfiguration() {
|
||||
config.BackendsPath = ui.backendsPathEntry.Text
|
||||
config.Address = ui.addressEntry.Text
|
||||
config.LogLevel = ui.logLevelSelect.Selected
|
||||
autoStart := ui.autoStartCheck.Checked
|
||||
config.AutoStart = &autoStart
|
||||
config.StartOnBoot = ui.startOnBootCheck.Checked
|
||||
|
||||
// Ensure environment variables are included in the configuration
|
||||
@@ -583,6 +588,7 @@ func (ui *LauncherUI) LoadConfiguration() {
|
||||
ui.backendsPathEntry.SetText(config.BackendsPath)
|
||||
ui.addressEntry.SetText(config.Address)
|
||||
ui.logLevelSelect.SetSelected(config.LogLevel)
|
||||
ui.autoStartCheck.SetChecked(config.AutoStart == nil || *config.AutoStart)
|
||||
ui.startOnBootCheck.SetChecked(config.StartOnBoot)
|
||||
|
||||
// Load environment variables
|
||||
@@ -616,6 +622,14 @@ func (ui *LauncherUI) UpdateRunningState(isRunning bool) {
|
||||
})
|
||||
}
|
||||
|
||||
// WelcomeDontShowAgainChecked reports the initial state of the welcome
|
||||
// window's "don't show this welcome window again" checkbox for the given
|
||||
// config: checked only when the user has already opted out of the welcome
|
||||
// window.
|
||||
func WelcomeDontShowAgainChecked(config *Config) bool {
|
||||
return config != nil && config.ShowWelcome != nil && !*config.ShowWelcome
|
||||
}
|
||||
|
||||
// ShowWelcomeWindow displays the welcome window with helpful information
|
||||
func (ui *LauncherUI) ShowWelcomeWindow() {
|
||||
if ui.launcher == nil || ui.launcher.window == nil {
|
||||
@@ -677,19 +691,20 @@ Getting Started:
|
||||
ui.openURL("https://discord.gg/XgwjKptP7Z")
|
||||
})
|
||||
|
||||
// Checkbox to disable welcome window
|
||||
dontShowAgainCheck := widget.NewCheck("Don't show this welcome window again", func(checked bool) {
|
||||
// Checkbox to disable welcome window. The initial state is applied
|
||||
// BEFORE the change callback is attached: SetChecked fires OnChanged,
|
||||
// and letting the initialization itself persist a ShowWelcome flip is
|
||||
// exactly the bug that suppressed this window forever after its first
|
||||
// showing (#11673).
|
||||
dontShowAgainCheck := widget.NewCheck("Don't show this welcome window again", nil)
|
||||
dontShowAgainCheck.SetChecked(WelcomeDontShowAgainChecked(ui.launcher.GetConfig()))
|
||||
dontShowAgainCheck.OnChanged = func(checked bool) {
|
||||
if ui.launcher != nil {
|
||||
config := ui.launcher.GetConfig()
|
||||
v := !checked
|
||||
config.ShowWelcome = &v
|
||||
ui.launcher.SetConfig(config)
|
||||
}
|
||||
})
|
||||
|
||||
config := ui.launcher.GetConfig()
|
||||
if config.ShowWelcome != nil {
|
||||
dontShowAgainCheck.SetChecked(*config.ShowWelcome)
|
||||
}
|
||||
|
||||
// Close button
|
||||
|
||||
@@ -22,6 +22,22 @@ Download the latest DMG from GitHub releases:
|
||||
3. Drag the LocalAI application to your Applications folder
|
||||
4. Launch LocalAI from your Applications folder
|
||||
|
||||
## First Launch
|
||||
|
||||
The app you installed is a small launcher that manages the LocalAI server for
|
||||
you. On the first launch it offers to download and install the latest server
|
||||
release; once that finishes, the server starts automatically and on every
|
||||
following launch of the app.
|
||||
|
||||
The launcher lives in the **menu bar** (look for the LocalAI icon in the top
|
||||
right of your screen) and does not open a window of its own. From the menu bar
|
||||
icon you can start and stop the server, open the WebUI, check for updates, and
|
||||
change settings, including turning off the automatic server start
|
||||
("Start LocalAI when the launcher opens" under Settings).
|
||||
|
||||
Once the server is running, the WebUI is available at
|
||||
`http://localhost:8080`.
|
||||
|
||||
## Verification
|
||||
|
||||
The `LocalAI.dmg` (and the app inside it) and the `local-ai` server binary are
|
||||
|
||||
Reference in new issue
Block a user