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:
Ettore Di Giacinto committed 2026-08-30 21:45:59 +00:00
1 parent dd4e75983d
commit 7aeb47cbf3
5 files changed
+178 -23

No files matched your search

+7 -2
View File
@@ -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
+61 -9
View File
@@ -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)
})
}
+72 -5
View File
@@ -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"))
})
+22 -7
View File
@@ -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
+16
View File
@@ -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