Skip to content

Commit 7aeb47c

Browse files
committed
fix(launcher): auto-start the server so launching the app actually serves
Fixes mudler#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>
1 parent dd4e759 commit 7aeb47c

5 files changed

Lines changed: 178 additions & 23 deletions

File tree

Makefile

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,11 @@ TEST_FLAKES?=5
3434
RANDOM := $(shell bash -c 'echo $$RANDOM')
3535

3636
VERSION?=$(shell git describe --always --tags || echo "dev" )
37+
# fyne package only accepts numeric x[.y[.z]] app versions, so reduce git
38+
# describe output (v4.9.0, v4.9.0-14-gabc1234, or a bare sha on untagged
39+
# checkouts) to its numeric core; anything non-numeric falls back to 0.0.0.
40+
# Without this the packaged launcher reports itself as version 0.0.0 (#11673).
41+
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")
3742
# go tool nm ./local-ai | grep Commit
3843
LD_FLAGS?=-s -w
3944
override LD_FLAGS += -X "github.com/mudler/LocalAI/internal.Version=$(VERSION)"
@@ -1622,7 +1627,7 @@ site-serve: site
16221627
build-launcher-darwin:
16231628
rm -rf dist/LocalAI.app cmd/launcher/LocalAI.app
16241629
mkdir -p dist
1625-
cd cmd/launcher && go run fyne.io/tools/cmd/fyne@latest package -os darwin -icon ../../core/http/static/logo.png --executable $(LAUNCHER_BINARY_NAME)
1630+
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)
16261631
mv cmd/launcher/LocalAI.app dist/LocalAI.app
16271632
bash contrib/macos/sign-and-notarize.sh sign dist/LocalAI.app
16281633

@@ -1649,4 +1654,4 @@ release-launcher-darwin: notarize-launcher-darwin
16491654
@echo "dist/LocalAI.dmg is ready"
16501655

16511656
build-launcher-linux:
1652-
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
1657+
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

cmd/launcher/internal/launcher.go

Lines changed: 61 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,17 @@ import (
2424

2525
// Config represents the launcher configuration
2626
type Config struct {
27-
ModelsPath string `json:"models_path"`
28-
BackendsPath string `json:"backends_path"`
29-
Address string `json:"address"`
30-
AutoStart bool `json:"auto_start"`
27+
ModelsPath string `json:"models_path"`
28+
BackendsPath string `json:"backends_path"`
29+
Address string `json:"address"`
30+
// AutoStart controls whether the launcher starts the LocalAI server as
31+
// soon as the launcher itself opens (and right after a fresh install).
32+
// Unset means enabled: launching the app must yield a serving endpoint,
33+
// which is what the quickstart docs promise. The JSON key is deliberately
34+
// not the legacy "auto_start": that field was never honored nor exposed
35+
// in any UI, so every existing launcher.json carries an unintentional
36+
// false that would keep auto-start permanently off (#11673).
37+
AutoStart *bool `json:"auto_start_server"`
3138
StartOnBoot bool `json:"start_on_boot"`
3239
LogLevel string `json:"log_level"`
3340
EnvironmentVars map[string]string `json:"environment_vars"`
@@ -122,9 +129,6 @@ func (l *Launcher) Initialize() error {
122129
log.Printf("Warning: failed to cleanup partial downloads: %v", err)
123130
}
124131

125-
if l.config.StartOnBoot {
126-
l.StartLocalAI()
127-
}
128132
// Set default paths if not configured (only if not already loaded from config)
129133
if l.config.ModelsPath == "" {
130134
homeDir, _ := os.UserHomeDir()
@@ -156,6 +160,12 @@ func (l *Launcher) Initialize() error {
156160
log.Printf("Setting default ShowWelcome: true")
157161
}
158162

163+
if l.config.AutoStart == nil {
164+
enabled := true
165+
l.config.AutoStart = &enabled
166+
log.Printf("Setting default AutoStart: true")
167+
}
168+
159169
// Create directories
160170
os.MkdirAll(l.config.ModelsPath, 0755)
161171
os.MkdirAll(l.config.BackendsPath, 0755)
@@ -177,6 +187,11 @@ func (l *Launcher) Initialize() error {
177187
l.showDownloadLocalAIDialog()
178188
}
179189
})
190+
} else if l.ShouldAutoStartServer() {
191+
// The launcher is a tray-only app: without this the user launches it,
192+
// sees no window and no server, and concludes it does nothing (#11673).
193+
log.Printf("Auto-starting LocalAI server")
194+
l.autoStartServer()
180195
}
181196

182197
// Check for updates periodically
@@ -185,6 +200,35 @@ func (l *Launcher) Initialize() error {
185200
return nil
186201
}
187202

203+
// ShouldAutoStartServer reports whether the launcher should start the server
204+
// without user interaction: at launcher startup and right after a fresh
205+
// install. Defaults to enabled; StartOnBoot forces a start even when
206+
// auto-start was explicitly disabled, preserving its historical behavior.
207+
func (l *Launcher) ShouldAutoStartServer() bool {
208+
if l.config == nil {
209+
return false
210+
}
211+
if l.config.StartOnBoot {
212+
return true
213+
}
214+
return l.config.AutoStart == nil || *l.config.AutoStart
215+
}
216+
217+
// autoStartServer starts LocalAI in the background and surfaces failures
218+
// through the systray error dialog: during an auto-start there is no visible
219+
// window for a regular error dialog to attach to.
220+
func (l *Launcher) autoStartServer() {
221+
go func() {
222+
if err := l.StartLocalAI(); err != nil {
223+
log.Printf("Failed to auto-start LocalAI: %v", err)
224+
l.updateStatus(fmt.Sprintf("Failed to start LocalAI: %v", err))
225+
if l.systray != nil {
226+
l.systray.showStartupErrorDialog(err)
227+
}
228+
}
229+
}()
230+
}
231+
188232
// StartLocalAI starts the LocalAI server
189233
func (l *Launcher) StartLocalAI() error {
190234
if l.isRunning {
@@ -644,14 +688,22 @@ func (l *Launcher) showDownloadError(title, message string) {
644688
// after a fresh install (no LocalAI binary present yet).
645689
func (l *Launcher) showDownloadProgress(version, title string) {
646690
l.showDownloadProgressWindow(version, title, func(win fyne.Window) {
647-
dialog.ShowConfirm("Installation Complete",
648-
"LocalAI has been downloaded and installed successfully. You can now start LocalAI from the launcher.",
691+
message := "LocalAI has been downloaded and installed successfully. You can now start LocalAI from the launcher."
692+
if l.ShouldAutoStartServer() {
693+
message = "LocalAI has been downloaded and installed successfully. It will start now: manage it and open the WebUI from the system tray icon."
694+
}
695+
dialog.ShowConfirm("Installation Complete", message,
649696
func(bool) {
650697
win.Close()
651698
l.updateStatus("LocalAI installed successfully")
652699
if l.systray != nil {
653700
l.systray.recreateMenu()
654701
}
702+
// A fresh install should end with a running server, not with
703+
// the user hunting for a start button in the tray (#11673).
704+
if l.ShouldAutoStartServer() && !l.isRunning {
705+
l.autoStartServer()
706+
}
655707
}, win)
656708
})
657709
}

cmd/launcher/internal/launcher_test.go

Lines changed: 72 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package launcher_test
22

33
import (
4+
"encoding/json"
45
"os"
56
"path/filepath"
67
"strings"
@@ -55,7 +56,8 @@ var _ = Describe("Launcher", func() {
5556
Expect(err).ToNot(HaveOccurred())
5657

5758
config := launcherInstance.GetConfig()
58-
Expect(config.ShowWelcome).To(BeTrue())
59+
Expect(config.ShowWelcome).ToNot(BeNil())
60+
Expect(*config.ShowWelcome).To(BeTrue())
5961
Expect(config.Address).To(Equal("127.0.0.1:8080"))
6062
Expect(config.LogLevel).To(Equal("info"))
6163
})
@@ -177,13 +179,53 @@ var _ = Describe("Launcher", func() {
177179

178180
assertFlagValue("--generated-content-path", filepath.Join(dataPath, "generated"))
179181
assertFlagValue("--upload-path", filepath.Join(dataPath, "uploads"))
180-
// The bug was the server resolving these to shared /tmp paths.
182+
// The bug was the server resolving these to its shared /tmp
183+
// defaults. Only reject those specific paths: on Linux the test's
184+
// own temp directory legitimately lives under /tmp.
181185
for _, a := range args {
182-
Expect(a).ToNot(HavePrefix("/tmp/"), "run args must not reference shared /tmp paths, got %s", a)
186+
Expect(a).ToNot(HavePrefix("/tmp/generated"), "run args must not reference the shared /tmp generated-content default, got %s", a)
187+
Expect(a).ToNot(HavePrefix("/tmp/upload"), "run args must not reference the shared /tmp upload default, got %s", a)
183188
}
184189
})
185190
})
186191

192+
// Regression for "Mac dmg launcher launches nothing" (issue #11673): the
193+
// launcher created empty log files and served nothing because nothing ever
194+
// started the server unless the unrelated "start on system boot" option was
195+
// enabled. Launching the app must yield a serving endpoint by default.
196+
Describe("ShouldAutoStartServer", func() {
197+
It("should auto-start by default when nothing is configured", func() {
198+
Expect(launcherInstance.ShouldAutoStartServer()).To(BeTrue())
199+
})
200+
201+
It("should respect an explicit opt-out", func() {
202+
config := launcherInstance.GetConfig()
203+
err := json.Unmarshal([]byte(`{"auto_start_server": false}`), config)
204+
Expect(err).ToNot(HaveOccurred())
205+
206+
Expect(launcherInstance.ShouldAutoStartServer()).To(BeFalse())
207+
})
208+
209+
It("should still auto-start when StartOnBoot is set even if auto-start is off", func() {
210+
config := launcherInstance.GetConfig()
211+
err := json.Unmarshal([]byte(`{"auto_start_server": false, "start_on_boot": true}`), config)
212+
Expect(err).ToNot(HaveOccurred())
213+
214+
Expect(launcherInstance.ShouldAutoStartServer()).To(BeTrue())
215+
})
216+
217+
It("should ignore the legacy auto_start key older launchers persisted as false", func() {
218+
// Old launchers marshaled the never-honored AutoStart field as
219+
// "auto_start": false into every launcher.json. That stale value
220+
// carries no user intent and must not disable auto-start.
221+
config := launcherInstance.GetConfig()
222+
err := json.Unmarshal([]byte(`{"auto_start": false}`), config)
223+
Expect(err).ToNot(HaveOccurred())
224+
225+
Expect(launcherInstance.ShouldAutoStartServer()).To(BeTrue())
226+
})
227+
})
228+
187229
Describe("Logs", func() {
188230
It("should return empty logs initially", func() {
189231
logs := launcherInstance.GetLogs()
@@ -210,21 +252,46 @@ var _ = Describe("Launcher", func() {
210252
})
211253
})
212254

255+
// Regression for the welcome window suppressing itself (part of issue
256+
// #11673): the "don't show this welcome window again" checkbox was
257+
// initialized with the ShowWelcome value itself, so on the very first
258+
// showing it came up checked AND its change callback persisted
259+
// ShowWelcome=false, hiding the welcome window forever.
260+
var _ = Describe("WelcomeDontShowAgainChecked", func() {
261+
It("should be unchecked when the welcome window is enabled", func() {
262+
show := true
263+
config := &launcher.Config{ShowWelcome: &show}
264+
Expect(launcher.WelcomeDontShowAgainChecked(config)).To(BeFalse())
265+
})
266+
267+
It("should be checked when the user opted out", func() {
268+
show := false
269+
config := &launcher.Config{ShowWelcome: &show}
270+
Expect(launcher.WelcomeDontShowAgainChecked(config)).To(BeTrue())
271+
})
272+
273+
It("should be unchecked when the preference is unset", func() {
274+
Expect(launcher.WelcomeDontShowAgainChecked(&launcher.Config{})).To(BeFalse())
275+
Expect(launcher.WelcomeDontShowAgainChecked(nil)).To(BeFalse())
276+
})
277+
})
278+
213279
var _ = Describe("Config", func() {
214280
It("should have proper JSON tags", func() {
281+
autoStart := true
215282
config := &launcher.Config{
216283
ModelsPath: "/test/models",
217284
BackendsPath: "/test/backends",
218285
Address: ":8080",
219-
AutoStart: true,
286+
AutoStart: &autoStart,
220287
LogLevel: "info",
221288
EnvironmentVars: map[string]string{"TEST": "value"},
222289
}
223290

224291
Expect(config.ModelsPath).To(Equal("/test/models"))
225292
Expect(config.BackendsPath).To(Equal("/test/backends"))
226293
Expect(config.Address).To(Equal(":8080"))
227-
Expect(config.AutoStart).To(BeTrue())
294+
Expect(*config.AutoStart).To(BeTrue())
228295
Expect(config.LogLevel).To(Equal("info"))
229296
Expect(config.EnvironmentVars).To(HaveKeyWithValue("TEST", "value"))
230297
})

cmd/launcher/internal/ui.go

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ type LauncherUI struct {
3434
backendsPathEntry *widget.Entry
3535
addressEntry *widget.Entry
3636
logLevelSelect *widget.Select
37+
autoStartCheck *widget.Check
3738
startOnBootCheck *widget.Check
3839

3940
// Environment Variables
@@ -75,6 +76,7 @@ func NewLauncherUI() *LauncherUI {
7576
backendsPathEntry: widget.NewEntry(),
7677
addressEntry: widget.NewEntry(),
7778
logLevelSelect: widget.NewSelect([]string{"error", "warn", "info", "debug", "trace"}, nil),
79+
autoStartCheck: widget.NewCheck("Start LocalAI when the launcher opens", nil),
7880
startOnBootCheck: widget.NewCheck("Start LocalAI on system boot", nil),
7981
logText: widget.NewMultiLineEntry(),
8082
progressBar: widget.NewProgressBar(),
@@ -117,6 +119,7 @@ func (ui *LauncherUI) createConfigTab() *fyne.Container {
117119
widget.NewLabel("Log Level:"),
118120
ui.logLevelSelect,
119121
),
122+
ui.autoStartCheck,
120123
ui.startOnBootCheck,
121124
))
122125

@@ -401,6 +404,8 @@ func (ui *LauncherUI) saveConfiguration() {
401404
config.BackendsPath = ui.backendsPathEntry.Text
402405
config.Address = ui.addressEntry.Text
403406
config.LogLevel = ui.logLevelSelect.Selected
407+
autoStart := ui.autoStartCheck.Checked
408+
config.AutoStart = &autoStart
404409
config.StartOnBoot = ui.startOnBootCheck.Checked
405410

406411
// Ensure environment variables are included in the configuration
@@ -583,6 +588,7 @@ func (ui *LauncherUI) LoadConfiguration() {
583588
ui.backendsPathEntry.SetText(config.BackendsPath)
584589
ui.addressEntry.SetText(config.Address)
585590
ui.logLevelSelect.SetSelected(config.LogLevel)
591+
ui.autoStartCheck.SetChecked(config.AutoStart == nil || *config.AutoStart)
586592
ui.startOnBootCheck.SetChecked(config.StartOnBoot)
587593

588594
// Load environment variables
@@ -616,6 +622,14 @@ func (ui *LauncherUI) UpdateRunningState(isRunning bool) {
616622
})
617623
}
618624

625+
// WelcomeDontShowAgainChecked reports the initial state of the welcome
626+
// window's "don't show this welcome window again" checkbox for the given
627+
// config: checked only when the user has already opted out of the welcome
628+
// window.
629+
func WelcomeDontShowAgainChecked(config *Config) bool {
630+
return config != nil && config.ShowWelcome != nil && !*config.ShowWelcome
631+
}
632+
619633
// ShowWelcomeWindow displays the welcome window with helpful information
620634
func (ui *LauncherUI) ShowWelcomeWindow() {
621635
if ui.launcher == nil || ui.launcher.window == nil {
@@ -677,19 +691,20 @@ Getting Started:
677691
ui.openURL("https://discord.gg/XgwjKptP7Z")
678692
})
679693

680-
// Checkbox to disable welcome window
681-
dontShowAgainCheck := widget.NewCheck("Don't show this welcome window again", func(checked bool) {
694+
// Checkbox to disable welcome window. The initial state is applied
695+
// BEFORE the change callback is attached: SetChecked fires OnChanged,
696+
// and letting the initialization itself persist a ShowWelcome flip is
697+
// exactly the bug that suppressed this window forever after its first
698+
// showing (#11673).
699+
dontShowAgainCheck := widget.NewCheck("Don't show this welcome window again", nil)
700+
dontShowAgainCheck.SetChecked(WelcomeDontShowAgainChecked(ui.launcher.GetConfig()))
701+
dontShowAgainCheck.OnChanged = func(checked bool) {
682702
if ui.launcher != nil {
683703
config := ui.launcher.GetConfig()
684704
v := !checked
685705
config.ShowWelcome = &v
686706
ui.launcher.SetConfig(config)
687707
}
688-
})
689-
690-
config := ui.launcher.GetConfig()
691-
if config.ShowWelcome != nil {
692-
dontShowAgainCheck.SetChecked(*config.ShowWelcome)
693708
}
694709

695710
// Close button

docs/content/getting-started/macos.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,22 @@ Download the latest DMG from GitHub releases:
2222
3. Drag the LocalAI application to your Applications folder
2323
4. Launch LocalAI from your Applications folder
2424

25+
## First Launch
26+
27+
The app you installed is a small launcher that manages the LocalAI server for
28+
you. On the first launch it offers to download and install the latest server
29+
release; once that finishes, the server starts automatically and on every
30+
following launch of the app.
31+
32+
The launcher lives in the **menu bar** (look for the LocalAI icon in the top
33+
right of your screen) and does not open a window of its own. From the menu bar
34+
icon you can start and stop the server, open the WebUI, check for updates, and
35+
change settings, including turning off the automatic server start
36+
("Start LocalAI when the launcher opens" under Settings).
37+
38+
Once the server is running, the WebUI is available at
39+
`http://localhost:8080`.
40+
2541
## Verification
2642

2743
The `LocalAI.dmg` (and the app inside it) and the `local-ai` server binary are

0 commit comments

Comments
 (0)