From 0ed859d5de7a0698b8827db7eea3002f28864b37 Mon Sep 17 00:00:00 2001 From: Tim Wurmbrand Date: Sun, 8 Nov 2026 02:16:53 +0100 Subject: [PATCH] feat: stop and start Flock from the CLI and the macOS panel Fixes #92 --- docs/cli.md | 2 ++ docs/installation.md | 14 ++++++++++++ internal/cli/install_darwin.go | 21 +++++++++++++++--- internal/cli/install_linux.go | 5 +++++ internal/cli/install_windows.go | 12 +++++++++++ internal/cli/restart.go | 31 +++++++++++++++++++++++++-- internal/cli/root.go | 2 +- macos/Sources/FlockUI/AppModel.swift | 16 +++++++++++++- macos/Sources/FlockUI/PanelView.swift | 23 +++++++++----------- 9 files changed, 106 insertions(+), 20 deletions(-) diff --git a/docs/cli.md b/docs/cli.md index e6e64bf6..2f008cb6 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -16,6 +16,8 @@ This page lists the `flock` commands. The other pages show where to find each ac | `flock uninstall [--purge]` | Remove Flock. Keeps site settings and databases unless `--purge` is given | | `flock status` | Show the state of the daemon and its services | | `flock restart` | Restart the daemon and all services | +| `flock start` | Start the daemon and all services after `flock stop` | +| `flock stop` | Stop the daemon and all services until `flock start` | | `flock logs [name] [-n 50] [-f]` | Show service logs (daemon, web, php8.5-fpm on macOS and Linux or php8.5-cgi on Windows, postgres, …). Without a name, lists the logs | | `flock update` | Update Flock, CLI and daemon included: the app installs the new version (on Linux, apt or dnf) | | `flock version` | Print the Flock version | diff --git a/docs/installation.md b/docs/installation.md index 762f599f..ea97d846 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -95,3 +95,17 @@ Stop that program, then click **Restart** at the bottom of the Flock panel. On L :::cli Stop that program and run `flock restart`. ::: + +## Stopping Flock + +Quitting the menu bar or tray app leaves Flock running, so your sites keep working. To stop Flock and everything it runs, for example to free memory, or ports 80 and 443 for another local environment: + +:::app +On macOS, switch off the switch at the top of the Flock panel. Switch it on to start Flock again. +::: + +:::cli +Run `flock stop`, and `flock start` to start Flock again. +::: + +Flock starts again at your next login. diff --git a/internal/cli/install_darwin.go b/internal/cli/install_darwin.go index 172795ec..20dd7489 100644 --- a/internal/cli/install_darwin.go +++ b/internal/cli/install_darwin.go @@ -487,11 +487,26 @@ func appDirs() []string { } } -// restartService makes launchd kill and relaunch the daemon. +// stopDaemon unloads the agent, which stops the daemon and everything it runs, until restartService +// or the next login loads it again. +func stopDaemon() error { + stopService("gui/" + strconv.Itoa(os.Getuid())) + return nil +} + +// restartService makes launchd kill and relaunch the daemon, or loads the agent after `flock stop`. func restartService() error { - target := "gui/" + strconv.Itoa(os.Getuid()) + "/" + launchLabel + domain := "gui/" + strconv.Itoa(os.Getuid()) + target := domain + "/" + launchLabel if err := exec.Command("launchctl", "print", target).Run(); err != nil { - return fmt.Errorf("the background daemon is not installed — run `flock install`") + path, _ := plistPath() + if !serviceInstalled() { + return fmt.Errorf("the background daemon is not installed — run `flock install`") + } + if out, err := exec.Command("launchctl", "bootstrap", domain, path).CombinedOutput(); err != nil { + return fmt.Errorf("launchctl bootstrap: %v: %s", err, out) + } + return nil } if out, err := exec.Command("launchctl", "kickstart", "-k", target).CombinedOutput(); err != nil { return fmt.Errorf("launchctl kickstart: %v: %s", err, out) diff --git a/internal/cli/install_linux.go b/internal/cli/install_linux.go index 21883d73..19d673b9 100644 --- a/internal/cli/install_linux.go +++ b/internal/cli/install_linux.go @@ -339,6 +339,11 @@ func uninstallService() error { return systemctl("stop", unitName(name)) } +// stopDaemon makes systemd stop the daemon until restartService or the next boot starts it. +func stopDaemon() error { + return uninstallService() +} + // restartService makes systemd stop and start the daemon. func restartService() error { name, err := serviceUser() diff --git a/internal/cli/install_windows.go b/internal/cli/install_windows.go index 4f85dc1f..cb61187c 100644 --- a/internal/cli/install_windows.go +++ b/internal/cli/install_windows.go @@ -492,6 +492,18 @@ func stopDaemonProcess() { _ = exec.Command("taskkill", "/F", "/IM", "flockd.exe").Run() } +// stopDaemon ends the scheduled task and the daemon, until restartService or the next login starts it. +func stopDaemon() error { + root, err := paths.Root() + if err != nil { + return err + } + _ = exec.Command("schtasks", "/End", "/TN", taskName).Run() + stopDaemonProcess() + waitDaemonGone(root) + return nil +} + // restartService ends the scheduled task (killing the daemon and everything it runs) and starts it again. func restartService() error { root, err := paths.Root() diff --git a/internal/cli/restart.go b/internal/cli/restart.go index c9a1cf1c..e22757f2 100644 --- a/internal/cli/restart.go +++ b/internal/cli/restart.go @@ -21,7 +21,11 @@ func newRestartCommand() *cobra.Command { return err } - before, _ := s.client().Status(cmd.Context()) + before, err := s.client().Status(cmd.Context()) + if err == nil && cmd.Name() == "start" { + cmd.Printf("already running (pid %d)\n", before.PID) + return nil + } if err := restartService(); err != nil { return err } @@ -31,7 +35,7 @@ func newRestartCommand() *cobra.Command { for time.Now().Before(deadline) { st, err := s.client().Status(cmd.Context()) if err == nil && st.PID != before.PID { - cmd.Printf("restarted (pid %d)\n", st.PID) + cmd.Printf("%sed (pid %d)\n", cmd.Name(), st.PID) // restarted, started for _, w := range st.Warnings { cmd.Println("warning: " + w) } @@ -46,3 +50,26 @@ func newRestartCommand() *cobra.Command { }, } } + +// newStartCommand starts the daemon after `flock stop` (restartService also loads it again) and +// leaves a running one alone. +func newStartCommand() *cobra.Command { + cmd := newRestartCommand() + cmd.Use, cmd.Short = "start", "Start the background daemon and all services after `flock stop`" + return cmd +} + +func newStopCommand() *cobra.Command { + return &cobra.Command{ + Use: "stop", + Short: "Stop the background daemon and all services until `flock start`", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + if err := stopDaemon(); err != nil { + return err + } + cmd.Println("stopped") + return nil + }, + } +} diff --git a/internal/cli/root.go b/internal/cli/root.go index ac2a5ba0..f92541e6 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -24,7 +24,7 @@ func NewRootCommand() *cobra.Command { root.AddCommand( newInstallCommand(), newConflictsCommand(), newUninstallCommand(), newPrivilegedSetupCommand(), - newDaemonCommand(), newRestartCommand(), newStatusCommand(), newLogsCommand(), newVersionCommand(), newUpdateCommand(), + newDaemonCommand(), newRestartCommand(), newStartCommand(), newStopCommand(), newStatusCommand(), newLogsCommand(), newVersionCommand(), newUpdateCommand(), newSitesCommand(), newLinkCommand(), newUnlinkCommand(), newParkCommand(), newUnparkCommand(), newProxyCommand(), newUnproxyCommand(), newProxiesCommand(), newSecureCommand(true), newSecureCommand(false), newDomainCommand(), newWorktreeCommand(), newShareCommand(), newUseCommand(), newIsolateCommand(), newUnisolateCommand(), newIsolatedCommand(), newPinCommand(), newUnpinCommand(), newRootCommand(), diff --git a/macos/Sources/FlockUI/AppModel.swift b/macos/Sources/FlockUI/AppModel.swift index dbe6dc6f..de2ac788 100644 --- a/macos/Sources/FlockUI/AppModel.swift +++ b/macos/Sources/FlockUI/AppModel.swift @@ -211,6 +211,20 @@ final class AppModel { /// Runs `flock restart` (which also starts a stopped daemon via launchd). func restart() async { + await run("restart") + } + + /// Runs `flock start` after `stop`. + func start() async { + await run("start") + } + + /// Runs `flock stop`: the daemon and everything it runs stop until `start`. + func stop() async { + await run("stop") + } + + private func run(_ command: String) async { isRestarting = true defer { isRestarting = false } @@ -221,7 +235,7 @@ final class AppModel { : "Flock is not installed for this user (\(binary.path) is missing) — run `flock install` once." return } - let result = await Installation.run(binary, ["restart"]) + let result = await Installation.run(binary, [command]) actionError = result.ok ? nil : result.output await refresh() } diff --git a/macos/Sources/FlockUI/PanelView.swift b/macos/Sources/FlockUI/PanelView.swift index 51646dd2..3e232410 100644 --- a/macos/Sources/FlockUI/PanelView.swift +++ b/macos/Sources/FlockUI/PanelView.swift @@ -72,6 +72,14 @@ struct PanelView: View { if model.isRestarting || model.isUpdating { ProgressView().controlSize(.small) } + // Flock's on/off switch: off stops the daemon and everything it runs. + if !model.needsSetup { + Toggle("Flock", isOn: Binding(get: { model.isRunning }, set: { on in Task { on ? await model.start() : await model.stop() } })) + .labelsHidden() + .toggleStyle(.switch) + .controlSize(.small) + .disabled(model.isRestarting || model.isUpdating) + } } } @@ -199,10 +207,10 @@ struct PanelView: View { private func toggles(_ status: Status) -> some View { VStack(alignment: .leading, spacing: 6) { Toggle(isOn: Binding(get: { status.xdebug }, set: { on in Task { await model.setXdebug(on) } })) { - Label("Xdebug", systemImage: "ladybug") + Label("Xdebug", systemImage: "ladybug").frame(maxWidth: .infinity, alignment: .leading) } Toggle(isOn: Binding(get: { status.dumps }, set: { on in Task { await model.setDumps(on) } })) { - Label("Capture dumps & queries", systemImage: "tray.and.arrow.down") + Label("Capture dumps & queries", systemImage: "tray.and.arrow.down").frame(maxWidth: .infinity, alignment: .leading) } } .toggleStyle(.switch) @@ -236,17 +244,6 @@ struct PanelView: View { } .buttonStyle(.borderedProminent) } - } else { - VStack(alignment: .leading, spacing: 8) { - Text(model.isUpdating ? "Updating Flock…" : model.error ?? "Flock is not running") - .font(.callout) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - Button("Start Flock") { - Task { await model.restart() } - } - .disabled(model.isRestarting || model.isUpdating) - } } }