Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion docs/cli/ghost_serve.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ ghost serve [flags]

# Pin a port and skip the browser
ghost serve --port 5174 --no-open

# Open in a dedicated app window
ghost serve --window
```

### Options
Expand All @@ -36,6 +39,7 @@ ghost serve [flags]
--log-level log level: debug, info, warn, or error (default INFO)
--no-open do not open the browser
--port int TCP port to listen on (0 = auto)
-w, --window open in a dedicated app window without an address bar (requires a Chromium-based browser)
```

### Options inherited from parent commands
Expand All @@ -49,4 +53,4 @@ ghost serve [flags]

### SEE ALSO

* [ghost](ghost.md) - CLI for managing Postgres databases
- [ghost](ghost.md) - CLI for managing Postgres databases
22 changes: 22 additions & 0 deletions internal/cmd/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ type runConfig struct {
envVars map[string]string
clientErr error // if set, the client factory returns this error (nil client)
openBrowser func(string) error // if set, overrides common.OpenBrowser for this test
openAppWindow func(string) error // if set, overrides common.OpenAppWindow for this test
newGhostClient func(string, api.AuthMethod) (api.ClientWithResponsesInterface, error) // if set, overrides api.NewGhostClient
credentials *config.Credentials // if set, seeded into the credentials file in the temp config dir
}
Expand Down Expand Up @@ -114,6 +115,15 @@ func withOpenBrowser(f func(string) error) runOption {
}
}

// withOpenAppWindow overrides common.OpenAppWindow for the duration of the
// test. By default, runCommand stubs OpenAppWindow to return an error. Use this
// to simulate a successful app-window open (pass a nil-returning func).
func withOpenAppWindow(f func(string) error) runOption {
return func(rc *runConfig) {
rc.openAppWindow = f
}
}

// withNewGhostClient overrides api.NewGhostClient for the duration of the test.
// Use this to intercept client creation in flows like login that create their
// own API client (bypassing the mock injected by the client factory).
Expand Down Expand Up @@ -208,6 +218,18 @@ func runCommand(
}
t.Cleanup(func() { common.OpenBrowser = originalOpenBrowser })

// Prevent app-window opens in tests (default: return error).
// Tests that need to simulate a successful app-window open use withOpenAppWindow.
originalOpenAppWindow := common.OpenAppWindow
if rc.openAppWindow != nil {
common.OpenAppWindow = rc.openAppWindow
} else {
common.OpenAppWindow = func(url string) error {
return errors.New("app window disabled in tests")
}
}
t.Cleanup(func() { common.OpenAppWindow = originalOpenAppWindow })

// Override api.NewGhostClient if requested (e.g. login tests)
if rc.newGhostClient != nil {
originalNewGhostClient := api.NewGhostClient
Expand Down
17 changes: 15 additions & 2 deletions internal/cmd/serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ func buildServeCmd(app *common.App) *cobra.Command {
var port int
var host string
var noOpen bool
var window bool
var logLevel slog.Level

cmd := &cobra.Command{
Expand All @@ -27,7 +28,10 @@ of this command — press Ctrl+C to stop it.`,
ghost serve

# Pin a port and skip the browser
ghost serve --port 5174 --no-open`,
ghost serve --port 5174 --no-open

# Open in a dedicated app window
ghost serve --window`,
Args: cobra.NoArgs,
ValidArgsFunction: cobra.NoFileCompletions,
SilenceUsage: true,
Expand Down Expand Up @@ -69,7 +73,14 @@ of this command — press Ctrl+C to stop it.`,
logger.Info("Listening", slog.String("url", url))

if !noOpen {
if err := common.OpenBrowser(url); err != nil {
if window {
if err := common.OpenAppWindow(url); err != nil {
logger.Warn("Failed to open app window; falling back to browser", slog.Any("error", err))
if err := common.OpenBrowser(url); err != nil {
logger.Warn("Failed to open browser", slog.Any("error", err))
}
}
} else if err := common.OpenBrowser(url); err != nil {
logger.Warn("Failed to open browser", slog.Any("error", err))
}
}
Expand All @@ -94,6 +105,8 @@ of this command — press Ctrl+C to stop it.`,
cmd.Flags().IntVar(&port, "port", 0, "TCP port to listen on (0 = auto)")
cmd.Flags().StringVar(&host, "host", "127.0.0.1", "interface to bind (loopback by default)")
cmd.Flags().BoolVar(&noOpen, "no-open", false, "do not open the browser")
cmd.Flags().BoolVarP(&window, "window", "w", false, "open in a dedicated app window without an address bar (requires a Chromium-based browser)")
cmd.MarkFlagsMutuallyExclusive("no-open", "window")
cmd.Flags().TextVar(&logLevel, "log-level", slog.LevelInfo, "log level: debug, info, warn, or error")

if err := cmd.RegisterFlagCompletionFunc("log-level", logLevelCompletion); err != nil {
Expand Down
40 changes: 40 additions & 0 deletions internal/cmd/serve_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,46 @@ func TestServeCmd(t *testing.T) {
},
stderrExcludes: []string{"Failed to open browser"},
},
{
name: "window opens app window and skips browser",
args: []string{"serve", "--window"},
opts: func(t *testing.T) []runOption {
ctx, cancel := context.WithCancel(context.Background())
cancel()
return []runOption{
withContext(ctx),
withOpenAppWindow(func(string) error { return nil }),
withOpenBrowser(func(string) error {
t.Fatal("OpenBrowser must not be called when the app window opens successfully")
return nil
}),
}
},
stderrExcludes: []string{
"Failed to open app window",
"Failed to open browser",
},
},
{
name: "window falls back to browser when no app-mode browser is found",
args: []string{"serve", "--window"},
opts: func(t *testing.T) []runOption {
ctx, cancel := context.WithCancel(context.Background())
cancel()
return []runOption{
withContext(ctx),
withOpenAppWindow(func(string) error { return errors.New("no Chromium-based browser found for app-window mode") }),
withOpenBrowser(func(string) error { return nil }),
}
},
stderrIncludes: []string{"Failed to open app window; falling back to browser"},
stderrExcludes: []string{"Failed to open browser"},
},
{
name: "window and no-open are mutually exclusive",
args: []string{"serve", "--window", "--no-open"},
wantErr: "if any flags in the group [no-open window] are set none of the others can be; [no-open window] were all set",
},
}

for _, tc := range tests {
Expand Down
78 changes: 78 additions & 0 deletions internal/common/browser.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
package common

import (
"errors"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
)
Expand Down Expand Up @@ -30,6 +33,81 @@ var OpenBrowser = func(url string) error {
return cmd.Run()
}

// ErrNoAppModeBrowser is returned by OpenAppWindow when no Chromium-based
// browser capable of "app mode" (the --app=<url> flag) could be found.
var ErrNoAppModeBrowser = errors.New("no Chromium-based browser found for app-window mode")

// OpenAppWindow opens the given URL in a standalone application window, without
// the address bar / tab strip, using a Chromium-based browser's --app=<url>
// flag. It searches for Chrome, Chromium, Edge, or Brave (in that order) and
// returns ErrNoAppModeBrowser if none is found, so callers can fall back to
// OpenBrowser. Declared as a var so tests can replace it with a no-op.
var OpenAppWindow = func(url string) error {
switch runtime.GOOS {
case "darwin":
return openAppWindowDarwin(url)
case "windows":
return openAppWindowWindows(url)
default: // "linux", "freebsd", "openbsd", "netbsd"
return openAppWindowLinux(url)
}
}

// openAppWindowDarwin launches a Chromium-based browser bundle in app mode.
// `open -na` returns as soon as the app is launched, so Run does not block on
// the window staying open.
func openAppWindowDarwin(url string) error {
apps := []string{"Google Chrome", "Chromium", "Microsoft Edge", "Brave Browser"}
searchDirs := []string{"/Applications", filepath.Join(os.Getenv("HOME"), "Applications")}
for _, app := range apps {
for _, dir := range searchDirs {
if _, err := os.Stat(filepath.Join(dir, app+".app")); err == nil {
return exec.Command("open", "-na", app, "--args", "--app="+url).Run()
}
}
}
return ErrNoAppModeBrowser
}

// openAppWindowLinux launches a Chromium-based browser in app mode. The browser
// is started detached (Start, not Run) because invoking the binary directly can
// block for the lifetime of the window when no instance is already running.
func openAppWindowLinux(url string) error {
exes := []string{"google-chrome", "google-chrome-stable", "chromium", "chromium-browser", "microsoft-edge", "brave-browser"}
for _, exe := range exes {
if path, err := exec.LookPath(exe); err == nil {
return exec.Command(path, "--app="+url).Start()
}
}
return ErrNoAppModeBrowser
}

// openAppWindowWindows launches a Chromium-based browser in app mode. As with
// Linux, the browser is started detached so it does not block on the window.
func openAppWindowWindows(url string) error {
var roots []string
for _, env := range []string{"ProgramFiles", "ProgramFiles(x86)", "LocalAppData"} {
if v := os.Getenv(env); v != "" {
roots = append(roots, v)
}
}
relPaths := []string{
filepath.Join("Google", "Chrome", "Application", "chrome.exe"),
filepath.Join("Chromium", "Application", "chrome.exe"),
filepath.Join("Microsoft", "Edge", "Application", "msedge.exe"),
filepath.Join("BraveSoftware", "Brave-Browser", "Application", "brave.exe"),
}
for _, rel := range relPaths {
for _, root := range roots {
path := filepath.Join(root, rel)
if _, err := os.Stat(path); err == nil {
return exec.Command(path, "--app="+url).Start()
}
}
}
return ErrNoAppModeBrowser
}

// OpenBrowserAsync opens the given URL in the user's default browser in a
// goroutine and returns a channel that receives an error if the browser command
// fails. This is used by the login flow to avoid blocking if the browser
Expand Down