From 6b093181d970880fcabc3b43349e9bd2761e6e4c Mon Sep 17 00:00:00 2001 From: Paulo Junior Date: Tue, 3 Nov 2026 16:50:06 -0300 Subject: [PATCH 1/7] feat(cli): add flock stop and flock start flock stop shuts the daemon down and every service it runs, and flock start starts it again. Neither changes the service, so the daemon still starts at the next login (on Linux, with the computer). On Windows the daemon has no signals, so it gets a new POST /v1/stop API call that ends Run as SIGTERM does: services shut down cleanly and the daemon exits with code 0, which the watchdog doesn't restart, so the scheduled task ends too. A daemon that doesn't stop within a minute, or one without the call, is ended like flock restart does. On macOS the LaunchAgent is booted out and bootstrapped again, on Linux the unit is stopped and started. Refs #92 --- docs/cli.md | 2 + internal/api/api.go | 13 ++++++ internal/api/api_test.go | 17 +++++++ internal/cli/install_darwin.go | 33 ++++++++++++++ internal/cli/install_linux.go | 26 +++++++++++ internal/cli/install_windows.go | 67 +++++++++++++++++++++++++++ internal/cli/root.go | 2 +- internal/cli/stop.go | 80 +++++++++++++++++++++++++++++++++ internal/daemon/daemon.go | 11 ++++- internal/daemon/daemon_test.go | 31 +++++++++++++ 10 files changed, 280 insertions(+), 2 deletions(-) create mode 100644 internal/cli/stop.go diff --git a/docs/cli.md b/docs/cli.md index cb2cea1f..57b16524 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -15,6 +15,8 @@ This page lists the `flock` commands. The other pages show where to find each ac | `flock install [--skip-privileged]` | Set Flock up: shims on PATH, background daemon, `.test` DNS and trusted HTTPS | | `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 stop` | Stop the daemon and all services until `flock start`, or until it starts by itself again (at the next login; on Linux, when the computer starts) | +| `flock start` | Start the daemon and all services after `flock stop` | | `flock restart` | Restart the daemon and all services | | `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) | diff --git a/internal/api/api.go b/internal/api/api.go index 329e13d9..771963d4 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -215,6 +215,8 @@ type Settings struct { type Backend interface { Status() Status Reload() error + // Stop shuts the daemon down: it stops every service and exits, as on SIGTERM. + Stop() Root() string // Mutate loads config.json, applies change, saves it and reloads the daemon. Mutate(change func(*config.Config) error) error @@ -271,6 +273,10 @@ func NewHandler(b Backend, token string) http.Handler { } writeJSON(w, http.StatusOK, b.Status()) }) + mux.HandleFunc("POST /v1/stop", func(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusOK, map[string]any{}) + b.Stop() + }) mux.HandleFunc("POST /v1/settings", func(w http.ResponseWriter, r *http.Request) { var settings Settings if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&settings); err != nil { @@ -361,6 +367,13 @@ func (c *Client) Reload(ctx context.Context) (Status, error) { return status, err } +// Stop asks the daemon to shut down. It returns once the daemon has accepted, which may still be +// stopping its services then. +func (c *Client) Stop(ctx context.Context) error { + var out struct{} + return c.do(ctx, http.MethodPost, "/v1/stop", &out) +} + // UpdateSettings changes the given global settings and returns the resulting status. func (c *Client) UpdateSettings(ctx context.Context, s Settings) (Status, error) { var status Status diff --git a/internal/api/api_test.go b/internal/api/api_test.go index be140086..728e70e9 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -34,6 +34,7 @@ type fakeBackend struct { pg *postgres.Manager frankenphp *frankenphp.Manager binaries *binaries.Catalog + stopped bool } func (f *fakeBackend) PHP() *php.Manager { return f.php } @@ -64,6 +65,7 @@ func (f *fakeBackend) RemoveWorktree(context.Context, string, bool) (string, err func (f *fakeBackend) Status() Status { return Status{Sites: f.sites} } func (f *fakeBackend) Reload() error { return nil } +func (f *fakeBackend) Stop() { f.stopped = true } func (f *fakeBackend) Root() string { return f.root } func (f *fakeBackend) ReleasePHP(string) {} func (f *fakeBackend) Services() []Service { return []Service{} } @@ -123,6 +125,21 @@ func TestTokenIsRequiredWhenConfigured(t *testing.T) { } } +func TestStopShutsTheDaemonDown(t *testing.T) { + b := &fakeBackend{root: t.TempDir()} + srv := serve(t, b, "secret") + + if resp, _ := call(t, "POST", srv.URL+"/v1/stop", "", ""); resp.StatusCode != http.StatusUnauthorized || b.stopped { + t.Fatalf("no token: %d, stopped %v", resp.StatusCode, b.stopped) + } + if resp, _ := call(t, "GET", srv.URL+"/v1/stop", "secret", ""); resp.StatusCode != http.StatusMethodNotAllowed || b.stopped { + t.Fatalf("GET: %d, stopped %v", resp.StatusCode, b.stopped) + } + if resp, body := call(t, "POST", srv.URL+"/v1/stop", "secret", ""); resp.StatusCode != http.StatusOK || !b.stopped { + t.Fatalf("POST: %d %s, stopped %v", resp.StatusCode, body, b.stopped) + } +} + func TestSettingsOnlyChangeGivenKeys(t *testing.T) { b := &fakeBackend{root: t.TempDir()} srv := serve(t, b, "") diff --git a/internal/cli/install_darwin.go b/internal/cli/install_darwin.go index 172795ec..852739f7 100644 --- a/internal/cli/install_darwin.go +++ b/internal/cli/install_darwin.go @@ -1,6 +1,7 @@ package cli import ( + "context" "fmt" "os" "os/exec" @@ -13,6 +14,7 @@ import ( "github.com/spf13/cobra" "golang.org/x/term" + "flock/internal/api" "flock/internal/certs" "flock/internal/dns" "flock/internal/node" @@ -166,7 +168,11 @@ func installService(root, bin string) error { domain := "gui/" + strconv.Itoa(os.Getuid()) stopService(domain) + return bootstrapAgent(domain, path) +} +// bootstrapAgent loads the agent at path, which starts the daemon (RunAtLoad). +func bootstrapAgent(domain, path string) error { // bootout returns before launchd has fully released the job, so bootstrap can briefly fail with EIO. var lastErr error for range 20 { @@ -487,6 +493,33 @@ func appDirs() []string { } } +// stopDaemon unloads the agent: launchd stops the daemon with SIGTERM, so its services shut down +// cleanly, and no longer keeps it alive. The plist stays, so the daemon starts at the next login. +func stopDaemon(context.Context, *api.Client, int) error { + if !serviceInstalled() { + return fmt.Errorf("the background daemon is not installed — run `flock install`") + } + stopService("gui/" + strconv.Itoa(os.Getuid())) + return nil +} + +// startDaemon loads the agent again after `flock stop`. +func startDaemon() error { + path, err := plistPath() + if err != nil { + return err + } + if !serviceInstalled() { + return fmt.Errorf("the background daemon is not installed — run `flock install`") + } + domain := "gui/" + strconv.Itoa(os.Getuid()) + if exec.Command("launchctl", "print", domain+"/"+launchLabel).Run() == nil { + // Loaded already: KeepAlive restarts the daemon by itself. + return nil + } + return bootstrapAgent(domain, path) +} + // restartService makes launchd kill and relaunch the daemon. func restartService() error { target := "gui/" + strconv.Itoa(os.Getuid()) + "/" + launchLabel diff --git a/internal/cli/install_linux.go b/internal/cli/install_linux.go index 21883d73..625e9844 100644 --- a/internal/cli/install_linux.go +++ b/internal/cli/install_linux.go @@ -2,6 +2,7 @@ package cli import ( "bytes" + "context" "errors" "fmt" "io/fs" @@ -17,6 +18,7 @@ import ( "github.com/spf13/cobra" "golang.org/x/term" + "flock/internal/api" "flock/internal/certs" "flock/internal/dns" "flock/internal/node" @@ -339,6 +341,30 @@ func uninstallService() error { return systemctl("stop", unitName(name)) } +// stopDaemon makes systemd stop the daemon (SIGTERM, so its services shut down cleanly). +func stopDaemon(context.Context, *api.Client, int) error { + name, err := serviceUser() + if err != nil { + return err + } + if !serviceInstalled() { + return fmt.Errorf("the background daemon is not installed — run `flock install`") + } + return systemctl("stop", unitName(name)) +} + +// startDaemon makes systemd start the daemon after `flock stop`. +func startDaemon() error { + name, err := serviceUser() + if err != nil { + return err + } + if !serviceInstalled() { + return fmt.Errorf("the background daemon is not installed — run `flock install`") + } + return systemctl("start", unitName(name)) +} + // 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..60b2052b 100644 --- a/internal/cli/install_windows.go +++ b/internal/cli/install_windows.go @@ -2,6 +2,7 @@ package cli import ( "bytes" + "context" "encoding/csv" "errors" "fmt" @@ -22,6 +23,7 @@ import ( "golang.org/x/sys/windows" "golang.org/x/sys/windows/registry" + "flock/internal/api" "flock/internal/certs" "flock/internal/dns" "flock/internal/hosts" @@ -514,6 +516,71 @@ func restartService() error { return fmt.Errorf("the background daemon is not installed — run `flock install`") } +// stopTimeout is how long `flock stop` waits for the daemon to stop its services: each gets up to +// 10 s to shut down cleanly before it is killed. +const stopTimeout = time.Minute + +// stopDaemon asks the daemon (pid, 0 when it doesn't answer) to shut down, which stops its services +// cleanly (PostgreSQL, MySQL and Valkey save their data) where ending the scheduled task kills them. +// The daemon then exits with code 0, so the watchdog ends instead of restarting it, and the task +// with it. The task stays as it is and starts the daemon again at the next sign-in. A daemon that +// doesn't stop in time, or one from before `flock stop`, is ended the way `flock restart` does. +func stopDaemon(ctx context.Context, client *api.Client, pid int) error { + root, err := paths.Root() + if err != nil { + return err + } + if pid != 0 && client.Stop(ctx) == nil && waitProcessExit(root, pid, stopTimeout) { + return nil + } + installed := taskInstalled() + if !installed && !runKeyInstalled() { + if pid != 0 { + return fmt.Errorf("the daemon (pid %d) did not stop — see `flock logs daemon`", pid) + } + return nil + } + if pid != 0 { + fmt.Println("! the daemon didn't stop by itself in time; ending it") + } + // a watchdog waiting to start a crashed daemon again. + if installed { + _ = exec.Command("schtasks", "/End", "/TN", taskName).Run() + } + stopDaemonProcess() + waitDaemonGone(root) + return nil +} + +// waitProcessExit waits up to timeout for the process pid to exit. +func waitProcessExit(root string, pid int, timeout time.Duration) bool { + p, err := windows.OpenProcess(windows.SYNCHRONIZE, false, uint32(pid)) + if err != nil { + // Gone already, or not ours to wait for: then at least wait for the API to go. + waitDaemonGone(root) + return true + } + defer windows.CloseHandle(p) + event, err := windows.WaitForSingleObject(p, uint32(timeout.Milliseconds())) + return err == nil && event == windows.WAIT_OBJECT_0 +} + +// startDaemon starts the scheduled task (or the Run key's command) and waits until the daemon answers. +func startDaemon() error { + root, err := paths.Root() + if err != nil { + return err + } + if taskInstalled() { + return runTask(root) + } + if runKeyInstalled() { + exe, args := daemonCommand(root) + return startDetached(exe, args) + } + return fmt.Errorf("the background daemon is not installed — run `flock install`") +} + // waitDaemonGone waits (up to 10 s) until no daemon answers on the socket any more. Ending the task // or the process returns before Windows has ended it, and a daemon started meanwhile finds the old // one still answering, exits as "already running", and its watchdog doesn't retry: nothing runs. diff --git a/internal/cli/root.go b/internal/cli/root.go index ed12da07..6a3069a6 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(), newStartCommand(), newStopCommand(), newRestartCommand(), newStatusCommand(), newLogsCommand(), newVersionCommand(), newUpdateCommand(), newSitesCommand(), newLinkCommand(), newUnlinkCommand(), newParkCommand(), newUnparkCommand(), newSecureCommand(true), newSecureCommand(false), newDomainCommand(), newWorktreeCommand(), newShareCommand(), newUseCommand(), newIsolateCommand(), newUnisolateCommand(), newIsolatedCommand(), newPinCommand(), newUnpinCommand(), newRootCommand(), diff --git a/internal/cli/stop.go b/internal/cli/stop.go new file mode 100644 index 00000000..154151fc --- /dev/null +++ b/internal/cli/stop.go @@ -0,0 +1,80 @@ +package cli + +import ( + "errors" + "fmt" + "time" + + "github.com/spf13/cobra" + + "flock/internal/api" +) + +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 { + s, err := load() + if err != nil { + return err + } + + st, err := s.client().Status(cmd.Context()) + if err != nil && !errors.Is(err, api.ErrDaemonNotRunning) { + return err + } + // Also when the daemon doesn't answer: the service may be about to start it again. + if err := stopDaemon(cmd.Context(), s.client(), st.PID); err != nil { + return err + } + if st.PID == 0 { + cmd.Println("not running") + } else { + // Stopping doesn't change the service: it still starts the daemon at login (Linux: with the computer). + cmd.Printf("stopped (pid %d); it starts again with `flock start`, or %s\n", st.PID, serviceStarts) + } + return nil + }, + } +} + +func newStartCommand() *cobra.Command { + return &cobra.Command{ + Use: "start", + Short: "Start the background daemon and all services after `flock stop`", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + s, err := load() + if err != nil { + return err + } + + if st, err := s.client().Status(cmd.Context()); err == nil { + cmd.Printf("already running (pid %d)\n", st.PID) + return nil + } + if err := startDaemon(); err != nil { + return err + } + + deadline := time.Now().Add(15 * time.Second) + for time.Now().Before(deadline) { + st, err := s.client().Status(cmd.Context()) + if err == nil { + cmd.Printf("started (pid %d)\n", st.PID) + for _, w := range st.Warnings { + cmd.Println("warning: " + w) + } + return nil + } + if !errors.Is(err, api.ErrDaemonNotRunning) { + return err + } + time.Sleep(200 * time.Millisecond) + } + return fmt.Errorf("daemon did not start within 15s, see `flock logs daemon`") + }, + } +} diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index a501345f..1369b805 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -107,8 +107,13 @@ type daemon struct { wildcard wildcardState defaults defaults // the first run's default PHP and Node.js + + stop context.CancelFunc // ends Run (see Stop) } +// Stop makes Run return: it stops every service, and the daemon exits with code 0. +func (d *daemon) Stop() { d.stop() } + func (d *daemon) shutdown() { // First, while reloads still work: stopping a share reloads the web server. if d.shares != nil { @@ -279,7 +284,11 @@ func Run(ctx context.Context, cfg Config) error { return err } - d := &daemon{root: cfg.Root, version: cfg.Version, startedAt: time.Now(), procs: supervisor.New(), leaves: certs.NewLeaves(), restarts: map[string]int{}} + // `flock stop` ends the daemon through the API (Stop), as a signal would. + ctx, stop := context.WithCancel(ctx) + defer stop() + + d := &daemon{root: cfg.Root, version: cfg.Version, startedAt: time.Now(), procs: supervisor.New(), leaves: certs.NewLeaves(), restarts: map[string]int{}, stop: stop} defer d.shutdown() d.binaries = binaries.NewCatalog(binaries.DefaultSource(cfg.Root), d.channel) diff --git a/internal/daemon/daemon_test.go b/internal/daemon/daemon_test.go index 0645658e..b6d84bad 100644 --- a/internal/daemon/daemon_test.go +++ b/internal/daemon/daemon_test.go @@ -253,6 +253,37 @@ func TestRefusesToStartTwice(t *testing.T) { } } +func TestStopEndsRun(t *testing.T) { + root, _ := testRoot(t, nil) + + done := make(chan error, 1) + go func() { done <- Run(context.Background(), Config{Root: root, Version: "dev"}) }() + client := api.NewClient(paths.Socket(root)) + deadline := time.Now().Add(5 * time.Second) + for _, err := client.Status(context.Background()); err != nil; _, err = client.Status(context.Background()) { + if time.Now().After(deadline) { + t.Fatal("daemon did not become ready") + } + time.Sleep(20 * time.Millisecond) + } + + if err := client.Stop(context.Background()); err != nil { + t.Fatal(err) + } + select { + case err := <-done: + // nil: the Windows watchdog only restarts a daemon that exits with an error. + if err != nil { + t.Fatalf("Run returned %v", err) + } + case <-time.After(10 * time.Second): + t.Fatal("daemon did not stop") + } + if _, err := client.Status(context.Background()); !errors.Is(err, api.ErrDaemonNotRunning) { + t.Fatalf("want ErrDaemonNotRunning after stop, got %v", err) + } +} + func TestRemovesStaleSocket(t *testing.T) { root, _ := testRoot(t, nil) if err := os.WriteFile(paths.Socket(root), nil, 0o600); err != nil { From a26c4deb0ccbe4ca9d5776035eb554cc964be6d2 Mon Sep 17 00:00:00 2001 From: Paulo Junior Date: Tue, 3 Nov 2026 16:50:06 -0300 Subject: [PATCH 2/7] feat(windows): add Stop Flock to the tray menu and panel The tray menu shows Stop Flock under Restart Flock while the daemon runs, and the panel has a Stop link next to Restart. Both run flock stop; afterwards the existing Start Flock item and button appear. Refs #92 --- docs/installation.md | 17 ++++++++++++++++ windows/Flock.Tray/App.xaml.cs | 6 ++++-- .../Infrastructure/RuntimeService.cs | 20 ++++++++++++++----- windows/Flock.Tray/PanelWindow.xaml | 3 +++ .../Flock.Tray/ViewModels/PanelViewModel.cs | 15 ++++++++++---- windows/README.md | 1 + 6 files changed, 51 insertions(+), 11 deletions(-) diff --git a/docs/installation.md b/docs/installation.md index 762f599f..828ed264 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -95,3 +95,20 @@ 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 + +Flock's daemon runs in the background, so your sites keep working when the app is closed. To free its memory when you're not working, stop it: every service (PHP, databases, mail, …) stops with it, and the databases shut down cleanly. + +:::app +On Windows, click **Stop** at the bottom of the Flock panel, or **Stop Flock** in the tray icon's menu. **Start Flock** starts it again. On macOS and Linux, run `flock stop` in a terminal, and `flock start` to start it again. +::: + +:::cli +```bash +flock stop +flock start +``` +::: + +Stopping lasts until you start Flock again, or until it starts by itself: at your next login (on Linux, when the computer starts). diff --git a/windows/Flock.Tray/App.xaml.cs b/windows/Flock.Tray/App.xaml.cs index cfbfb561..ad07439f 100644 --- a/windows/Flock.Tray/App.xaml.cs +++ b/windows/Flock.Tray/App.xaml.cs @@ -282,9 +282,10 @@ private void ShowMenu() // While an update is available it follows the first item; the rest keep their order. var update = State.UpdateService.Available is { } available && !State.UpdateService.IsBusy ? $"Update to {available.Version}…" : null; // Before the setup there is nothing to open yet: Mail, Dumps, Logs and Settings all need the daemon. + var running = State.IsRunning; List items = State.NeedsSetup ? ["Set Up Flock…", null, "Quit Flock"] - : ["Open Flock", null, "Mail", "Dumps", "Logs", "Settings…", null, State.IsRunning ? "Restart Flock" : "Start Flock", "Quit Flock"]; + : ["Open Flock", null, "Mail", "Dumps", "Logs", "Settings…", null, .. running ? new[] { "Restart Flock", "Stop Flock" } : ["Start Flock"], "Quit Flock"]; if (update is not null) { items.Insert(1, update); @@ -316,7 +317,8 @@ private void ShowMenu() case 4: Open(WindowKind.Logs); break; case 5: Open(WindowKind.Settings); break; case 7: _ = State.RestartAsync(); break; - case 8: Quit(); break; + case 8 when running: _ = State.StopAsync(); break; + case 8 or 9: Quit(); break; } } diff --git a/windows/Flock.Tray/Infrastructure/RuntimeService.cs b/windows/Flock.Tray/Infrastructure/RuntimeService.cs index 215dd591..e8764253 100644 --- a/windows/Flock.Tray/Infrastructure/RuntimeService.cs +++ b/windows/Flock.Tray/Infrastructure/RuntimeService.cs @@ -77,6 +77,7 @@ public RuntimeService(FlockClient client, UpdateService updates, InstallationSer public string? ActionError { get; private set; } public string? RuntimeError { get; private set; } public bool IsRestarting { get; private set; } + public bool IsStopping { get; private set; } public bool NeedsSetup { get; set; } public InstallLink? PendingInstall { @@ -179,20 +180,29 @@ public void DismissError() NotifyChanged(); } - public async Task RestartAsync() + /// Restarts the daemon and its services (flock restart); also starts a stopped daemon. + public Task RestartAsync() => RunLifecycleAsync("restart", busy => IsRestarting = busy); + + /// + /// Stops the daemon and its services (flock stop) until Start Flock, or the next sign-in: the scheduled + /// task stays as it is. + /// + public Task StopAsync() => RunLifecycleAsync("stop", busy => IsStopping = busy); + + private async Task RunLifecycleAsync(string command, Action setBusy) { if (operations.IsRunning("lifecycle")) { return; } - IsRestarting = true; + setBusy(true); NotifyChanged(); var outcome = await operations.RunMutationAsync("lifecycle", operationErrors, async token => { - var result = await processRunner.RunAsync(FlockPaths.FlockBinary, ["restart"], TimeSpan.FromMinutes(2), token); + var result = await processRunner.RunAsync(FlockPaths.FlockBinary, [command], TimeSpan.FromMinutes(2), token); if (!result.Succeeded) { - throw new FlockException(result.Output.Length == 0 ? $"Could not restart Flock ({FlockPaths.FlockBinary})." : result.Output); + throw new FlockException(result.Output.Length == 0 ? $"Could not {command} Flock ({FlockPaths.FlockBinary})." : result.Output); } }, CancellationToken.None); ActionError = outcome switch @@ -201,7 +211,7 @@ public async Task RestartAsync() OperationOutcome.Succeeded => null, _ => ActionError, }; - IsRestarting = false; + setBusy(false); NotifyChanged(); if (outcome is OperationOutcome.Succeeded or OperationOutcome.Failed) { diff --git a/windows/Flock.Tray/PanelWindow.xaml b/windows/Flock.Tray/PanelWindow.xaml index 6e9900d0..72fb9065 100644 --- a/windows/Flock.Tray/PanelWindow.xaml +++ b/windows/Flock.Tray/PanelWindow.xaml @@ -317,6 +317,9 @@ + diff --git a/windows/Flock.Tray/ViewModels/PanelViewModel.cs b/windows/Flock.Tray/ViewModels/PanelViewModel.cs index 013578c0..c32a53d9 100644 --- a/windows/Flock.Tray/ViewModels/PanelViewModel.cs +++ b/windows/Flock.Tray/ViewModels/PanelViewModel.cs @@ -28,7 +28,8 @@ public PanelViewModel(RuntimeService model) model.Dumps.Changed += DumpsChanged; ToggleDumpsCommand = new AsyncRelayCommand(SetDumpsAsync, _ => !model.Dumps.IsCaptureBusy); ToggleXdebugCommand = new AsyncRelayCommand(SetXdebugAsync); - RestartCommand = new AsyncRelayCommand(model.RestartAsync, () => !IsRestarting); + RestartCommand = new AsyncRelayCommand(model.RestartAsync, () => !IsRestarting && !IsStopping); + StopCommand = new AsyncRelayCommand(model.StopAsync, () => StopEnabled); InstallUpdateCommand = new RelayCommand(() => App.Current.InstallUpdate(), () => CanInstallUpdate); OpenMailCommand = new RelayCommand(() => App.Current.Open(WindowKind.Mail)); OpenDumpsCommand = new RelayCommand(() => App.Current.Open(WindowKind.Dumps)); @@ -72,6 +73,7 @@ public bool Dumps public bool IsRunning => model.IsRunning; public bool IsDegraded => model.IsDegraded; public bool IsRestarting => model.IsRestarting; + public bool IsStopping => model.IsStopping; public bool IsUpdating => model.IsUpdating; public bool IsCaptureBusy => model.Dumps.IsCaptureBusy; public bool NeedsSetup => model.NeedsSetup; @@ -89,7 +91,7 @@ public bool Dumps public string UpdateMessage => !IsUpdateBusy && AvailableUpdate is { } update ? $"Flock {update.Version} is available" : model.UpdateService.Summary; public int MailCount => Status?.MailCount ?? 0; public int DumpCount => Status?.DumpCount ?? 0; - public Visibility RestartVisibility => IsRestarting ? Visibility.Visible : Visibility.Collapsed; + public Visibility RestartVisibility => IsRestarting || IsStopping ? Visibility.Visible : Visibility.Collapsed; public Visibility UpdateVisibility => HasUpdate ? Visibility.Visible : Visibility.Collapsed; public Visibility StatusContentVisibility => HasStatusContent ? Visibility.Visible : Visibility.Collapsed; public Visibility WarningsVisibility => Status?.Warnings.Count > 0 ? Visibility.Visible : Visibility.Collapsed; @@ -103,13 +105,15 @@ public bool Dumps public Visibility MailCountVisibility => MailCount > 0 ? Visibility.Visible : Visibility.Collapsed; public Visibility DumpCountVisibility => DumpCount > 0 ? Visibility.Visible : Visibility.Collapsed; public bool CaptureEnabled => !IsCaptureBusy; - public bool StartEnabled => !IsRestarting; - public bool RestartEnabled => IsRunning && !IsRestarting; + public bool StartEnabled => !IsRestarting && !IsStopping; + public bool RestartEnabled => IsRunning && !IsRestarting && !IsStopping; + public bool StopEnabled => RestartEnabled; public ObservableCollection Sites { get; } = []; public ObservableCollection Services { get; } = []; public IAsyncRelayCommand ToggleDumpsCommand { get; } public IAsyncRelayCommand ToggleXdebugCommand { get; } public IAsyncRelayCommand RestartCommand { get; } + public IAsyncRelayCommand StopCommand { get; } public IRelayCommand InstallUpdateCommand { get; } public IRelayCommand OpenMailCommand { get; } public IRelayCommand OpenDumpsCommand { get; } @@ -188,6 +192,7 @@ private void ModelChanged() OnPropertyChanged(nameof(IsRunning)); OnPropertyChanged(nameof(IsDegraded)); OnPropertyChanged(nameof(IsRestarting)); + OnPropertyChanged(nameof(IsStopping)); OnPropertyChanged(nameof(IsUpdating)); OnPropertyChanged(nameof(IsCaptureBusy)); OnPropertyChanged(nameof(NeedsSetup)); @@ -221,7 +226,9 @@ private void ModelChanged() OnPropertyChanged(nameof(CaptureEnabled)); OnPropertyChanged(nameof(StartEnabled)); OnPropertyChanged(nameof(RestartEnabled)); + OnPropertyChanged(nameof(StopEnabled)); RestartCommand.NotifyCanExecuteChanged(); + StopCommand.NotifyCanExecuteChanged(); ToggleDumpsCommand.NotifyCanExecuteChanged(); InstallUpdateCommand.NotifyCanExecuteChanged(); } diff --git a/windows/README.md b/windows/README.md index 922033d4..63e972c0 100644 --- a/windows/README.md +++ b/windows/README.md @@ -126,6 +126,7 @@ its default Git description may not be a version the updater can compare. - [ ] Mail: send from tinker → appears in Mail, HTML view, attachments save - [ ] Dumps: `dump()` in a route → Dumps window, the file link opens the editor - [ ] `flock restart` and the tray's Restart bring the daemon back with a new pid +- [ ] `flock stop` and the tray's Stop end the daemon, its services (with the clean shutdowns above) and flockd.exe, and nothing starts them again; the menu then offers Start Flock. `flock start` and Start Flock bring it back, and so does signing out and in - [ ] Ports 80/443 taken (IIS, Skype…) → the warning names the program - [ ] Sign out and in → daemon starts; with "Open Flock at sign-in" on, the tray icon too - [ ] `flock uninstall` removes the task, PATH entry, hosts block, helper service, CA and Flock's From f50013015fcc3f4a25ecb1b8e5544598d09bed65 Mon Sep 17 00:00:00 2001 From: Paulo Junior Date: Tue, 3 Nov 2026 17:10:03 -0300 Subject: [PATCH 3/7] feat(windows): rename Quit Flock to Quit Flock Tray Quitting the tray app leaves the daemon and the sites running, which "Quit Flock" didn't make clear. The macOS app says "Quit Flock Bar" for the same reason. Linux keeps "Quit Flock": its app can also be a window without a tray icon. Refs #92 --- windows/Flock.Tray/App.xaml.cs | 4 ++-- windows/Flock.Tray/PanelWindow.xaml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/windows/Flock.Tray/App.xaml.cs b/windows/Flock.Tray/App.xaml.cs index ad07439f..ef0a9015 100644 --- a/windows/Flock.Tray/App.xaml.cs +++ b/windows/Flock.Tray/App.xaml.cs @@ -284,8 +284,8 @@ private void ShowMenu() // Before the setup there is nothing to open yet: Mail, Dumps, Logs and Settings all need the daemon. var running = State.IsRunning; List items = State.NeedsSetup - ? ["Set Up Flock…", null, "Quit Flock"] - : ["Open Flock", null, "Mail", "Dumps", "Logs", "Settings…", null, .. running ? new[] { "Restart Flock", "Stop Flock" } : ["Start Flock"], "Quit Flock"]; + ? ["Set Up Flock…", null, "Quit Flock Tray"] + : ["Open Flock", null, "Mail", "Dumps", "Logs", "Settings…", null, .. running ? new[] { "Restart Flock", "Stop Flock" } : ["Start Flock"], "Quit Flock Tray"]; if (update is not null) { items.Insert(1, update); diff --git a/windows/Flock.Tray/PanelWindow.xaml b/windows/Flock.Tray/PanelWindow.xaml index 72fb9065..42a680b3 100644 --- a/windows/Flock.Tray/PanelWindow.xaml +++ b/windows/Flock.Tray/PanelWindow.xaml @@ -322,7 +322,7 @@ ToolTipService.ToolTip="Stops Flock and every service until Start Flock or the next sign-in" /> - + From 82241c993fb33d59d6b8f2586c865f14093c8ce6 Mon Sep 17 00:00:00 2001 From: Paulo Junior Date: Tue, 3 Nov 2026 18:21:30 -0300 Subject: [PATCH 4/7] fix(cli): let flock restart start a daemon stopped on macOS flock stop unloads the LaunchAgent, and flock restart then said the daemon wasn't installed, so the apps' Start Flock (which runs flock restart) failed after a stop. It now loads the agent again when it isn't loaded. Refs #92 --- internal/cli/install_darwin.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/internal/cli/install_darwin.go b/internal/cli/install_darwin.go index 852739f7..4245299e 100644 --- a/internal/cli/install_darwin.go +++ b/internal/cli/install_darwin.go @@ -520,11 +520,12 @@ func startDaemon() error { return bootstrapAgent(domain, path) } -// restartService makes launchd kill and relaunch the daemon. +// restartService makes launchd kill and relaunch the daemon, or loads the agent again after +// `flock stop` (the apps' Start Flock runs `flock restart`). func restartService() error { target := "gui/" + strconv.Itoa(os.Getuid()) + "/" + launchLabel if err := exec.Command("launchctl", "print", target).Run(); err != nil { - return fmt.Errorf("the background daemon is not installed — run `flock install`") + return startDaemon() } if out, err := exec.Command("launchctl", "kickstart", "-k", target).CombinedOutput(); err != nil { return fmt.Errorf("launchctl kickstart: %v: %s", err, out) From b340d87404532e2c2748954e62bdb9ee35fb71c6 Mon Sep 17 00:00:00 2001 From: Paulo Junior Date: Tue, 3 Nov 2026 18:21:31 -0300 Subject: [PATCH 5/7] feat: add Stop Daemon to Settings > General in each app The Daemon row in Settings > General has Stop Daemon next to Restart Daemon on Windows, macOS and Linux. While the daemon is stopped, Settings already shows only Start Flock instead of the panes. After a successful stop the Windows and Linux apps show the daemon as stopped at once, instead of the stale status until the next poll: on Windows, reading the status of a daemon that just stopped takes seconds to fail. Refs #92 --- docs/installation.md | 2 +- linux/src/model.rs | 23 ++++++++++++++++ linux/src/panes/general.rs | 8 +++++- linux/src/window.rs | 2 +- macos/Sources/FlockUI/AppModel.swift | 11 ++++++++ .../FlockUI/Settings/GeneralPane.swift | 17 ++++++++---- .../FlockUI/Settings/SettingsWindow.swift | 2 +- .../Flock.Tray.Tests/GeneralViewModelTests.cs | 27 +++++++++++++++++++ .../Infrastructure/RuntimeService.cs | 19 ++++++++----- windows/Flock.Tray/Settings/GeneralPane.xaml | 17 +++++++----- .../Flock.Tray/ViewModels/GeneralViewModel.cs | 21 ++++++++++++--- 11 files changed, 124 insertions(+), 25 deletions(-) diff --git a/docs/installation.md b/docs/installation.md index 828ed264..d5b6c208 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -101,7 +101,7 @@ Stop that program and run `flock restart`. Flock's daemon runs in the background, so your sites keep working when the app is closed. To free its memory when you're not working, stop it: every service (PHP, databases, mail, …) stops with it, and the databases shut down cleanly. :::app -On Windows, click **Stop** at the bottom of the Flock panel, or **Stop Flock** in the tray icon's menu. **Start Flock** starts it again. On macOS and Linux, run `flock stop` in a terminal, and `flock start` to start it again. +Click **Stop Daemon** in **Settings → General**. On Windows, **Stop** at the bottom of the Flock panel and **Stop Flock** in the tray icon's menu do the same. While Flock is stopped, Settings shows **Start Flock**, which starts it again. ::: :::cli diff --git a/linux/src/model.rs b/linux/src/model.rs index d4e3c729..95bf6633 100644 --- a/linux/src/model.rs +++ b/linux/src/model.rs @@ -25,6 +25,7 @@ pub struct AppModel { action_error: RefCell>, needs_setup: Cell, restarting: Cell, + stopping: Cell, polling: Cell, /// A flock:// link the pane it opened asks about once its data is loaded. link: RefCell>, @@ -66,6 +67,10 @@ impl AppModel { self.restarting.get() } + pub fn is_stopping(&self) -> bool { + self.stopping.get() + } + pub fn link(&self) -> Option { self.link.borrow().clone() } @@ -173,6 +178,24 @@ impl AppModel { }); } + /// Runs `flock stop`: the daemon and its services stop until Start Flock or the next boot. + pub fn stop(self: &Rc) { + self.stopping.set(true); + self.changed(); + let this = self.clone(); + glib::spawn_future_local(async move { + let out = gio::spawn_blocking(|| installation::run(&["stop"])).await.expect("flock stop"); + this.stopping.set(false); + // Stopped: no stale status (and its Stop and Restart buttons) until the next poll. + if out.ok { + this.status.replace(None); + } + this.action_error.replace(if out.ok { None } else { Some(out.text) }); + this.changed(); + this.refresh(); + }); + } + /// Reports a failed action in the main window's banner. pub fn report(&self, error: Option) { self.action_error.replace(error); diff --git a/linux/src/panes/general.rs b/linux/src/panes/general.rs index 6c71c5ca..0b2a0671 100644 --- a/linux/src/panes/general.rs +++ b/linux/src/panes/general.rs @@ -51,9 +51,15 @@ impl GeneralPane { group.add(&info_row("Version", &status.version)); group.add(&info_row("Process", &format!("pid {}", status.pid))); + let busy = self.model.is_restarting() || self.model.is_stopping(); + let model = self.model.clone(); + let stop = busy_button("Stop Daemon", "Stopping…", self.model.is_stopping(), move || model.stop()); + stop.set_sensitive(!busy); let model = self.model.clone(); let restart = busy_button("Restart Daemon", "Restarting…", self.model.is_restarting(), move || model.restart()); + restart.set_sensitive(!busy); let row = adw::ActionRow::new(); + row.add_suffix(&stop); row.add_suffix(&restart); group.add(&row); group @@ -232,7 +238,7 @@ impl Pane for GeneralPane { fn update(&self, data: &Data) { let Some(status) = data.status.clone() else { return }; - let key = (status.clone(), data.config.clone(), self.model.is_restarting()); + let key = (status.clone(), data.config.clone(), self.model.is_restarting() || self.model.is_stopping()); if self.shown.borrow().as_ref() == Some(&key) { return; } diff --git a/linux/src/window.rs b/linux/src/window.rs index de6d1b19..86c06cf5 100644 --- a/linux/src/window.rs +++ b/linux/src/window.rs @@ -288,7 +288,7 @@ impl MainWindow { self.unavailable .set_description(Some(&self.model.error().unwrap_or_else(|| "Start the daemon to change its settings.".to_owned()))); self.unavailable_button.set_label("Start Flock"); - self.unavailable_button.set_sensitive(!self.model.is_restarting()); + self.unavailable_button.set_sensitive(!self.model.is_restarting() && !self.model.is_stopping()); self.stack.set_visible_child_name("unavailable"); return; } diff --git a/macos/Sources/FlockUI/AppModel.swift b/macos/Sources/FlockUI/AppModel.swift index dbe6dc6f..c4f087c9 100644 --- a/macos/Sources/FlockUI/AppModel.swift +++ b/macos/Sources/FlockUI/AppModel.swift @@ -17,6 +17,7 @@ final class AppModel { /// it is dismissed or the next user action succeeds. var actionError: String? private(set) var isRestarting = false + private(set) var isStopping = false /// Flock isn't set up for this user yet, and this app can do it (see Installation). var needsSetup: Bool @@ -226,6 +227,16 @@ final class AppModel { await refresh() } + /// Runs `flock stop`: the daemon and its services stop until Start Flock or the next login. + func stop() async { + isStopping = true + defer { isStopping = false } + + let result = await Installation.run(FlockPaths.flockBinary, ["stop"]) + actionError = result.ok ? nil : result.output + await refresh() + } + /// Runs the bundled `flock install` after the app was updated. It restarts the daemon; macOS /// asks for the password only if DNS or the CA need setting up again. func updateInstallation() async { diff --git a/macos/Sources/FlockUI/Settings/GeneralPane.swift b/macos/Sources/FlockUI/Settings/GeneralPane.swift index df7d58a8..25d7d588 100644 --- a/macos/Sources/FlockUI/Settings/GeneralPane.swift +++ b/macos/Sources/FlockUI/Settings/GeneralPane.swift @@ -43,12 +43,19 @@ struct GeneralPane: View { LabeledContent("Process", value: "pid \(status.pid)") } LabeledContent("") { - Button { - Task { await model.restart() } - } label: { - BusyLabel(title: "Restart Daemon", busyTitle: "Restarting…", isBusy: model.isRestarting) + HStack { + Button { + Task { await model.stop() } + } label: { + BusyLabel(title: "Stop Daemon", busyTitle: "Stopping…", isBusy: model.isStopping) + } + Button { + Task { await model.restart() } + } label: { + BusyLabel(title: "Restart Daemon", busyTitle: "Restarting…", isBusy: model.isRestarting) + } } - .disabled(model.isRestarting) + .disabled(model.isRestarting || model.isStopping) } } diff --git a/macos/Sources/FlockUI/Settings/SettingsWindow.swift b/macos/Sources/FlockUI/Settings/SettingsWindow.swift index 3a8a1f7a..b71eb3ae 100644 --- a/macos/Sources/FlockUI/Settings/SettingsWindow.swift +++ b/macos/Sources/FlockUI/Settings/SettingsWindow.swift @@ -124,7 +124,7 @@ struct SettingsWindow: View { Text(model.error ?? "Start the daemon to change its settings.") } actions: { Button("Start Flock") { Task { await model.restart() } } - .disabled(model.isRestarting) + .disabled(model.isRestarting || model.isStopping) } } } else { diff --git a/windows/Flock.Tray.Tests/GeneralViewModelTests.cs b/windows/Flock.Tray.Tests/GeneralViewModelTests.cs index 7c2bd862..04beaea4 100644 --- a/windows/Flock.Tray.Tests/GeneralViewModelTests.cs +++ b/windows/Flock.Tray.Tests/GeneralViewModelTests.cs @@ -90,6 +90,33 @@ public async Task RestartReEnablesOnceTheRestartHasFinished() Assert.True(notified > 0); } + [Fact] + public async Task StopIsOfferedWhileRunningAndBlocksRestartUntilItHasFinished() + { + Status? status = null; + var stopping = false; + var finish = new TaskCompletionSource(); + using var stoppable = new GeneralViewModel(configuration, () => Task.CompletedTask, () => false, () => status, + stop: () => finish.Task, isStopping: () => stopping); + Assert.False(stoppable.StopCommand.CanExecute(null)); + + status = new Status { Pid = 42 }; + Assert.True(stoppable.StopCommand.CanExecute(null)); + stopping = true; + var stop = stoppable.StopCommand.ExecuteAsync(null); + Assert.True(stoppable.IsStopping); + Assert.False(stoppable.StopCommand.CanExecute(null)); + Assert.False(stoppable.RestartCommand.CanExecute(null)); + + stopping = false; + status = null; + finish.SetResult(); + await stop; + + Assert.False(stoppable.StopCommand.CanExecute(null)); + Assert.True(stoppable.RestartCommand.CanExecute(null)); + } + private sealed class Handler : HttpMessageHandler { public bool FailWrites { get; set; } diff --git a/windows/Flock.Tray/Infrastructure/RuntimeService.cs b/windows/Flock.Tray/Infrastructure/RuntimeService.cs index e8764253..48e9c2cc 100644 --- a/windows/Flock.Tray/Infrastructure/RuntimeService.cs +++ b/windows/Flock.Tray/Infrastructure/RuntimeService.cs @@ -181,15 +181,15 @@ public void DismissError() } /// Restarts the daemon and its services (flock restart); also starts a stopped daemon. - public Task RestartAsync() => RunLifecycleAsync("restart", busy => IsRestarting = busy); + public Task RestartAsync() => RunLifecycleAsync("restart", busy => IsRestarting = busy, stops: false); /// /// Stops the daemon and its services (flock stop) until Start Flock, or the next sign-in: the scheduled /// task stays as it is. /// - public Task StopAsync() => RunLifecycleAsync("stop", busy => IsStopping = busy); + public Task StopAsync() => RunLifecycleAsync("stop", busy => IsStopping = busy, stops: true); - private async Task RunLifecycleAsync(string command, Action setBusy) + private async Task RunLifecycleAsync(string command, Action setBusy, bool stops) { if (operations.IsRunning("lifecycle")) { @@ -211,12 +211,19 @@ private async Task RunLifecycleAsync(string command, Action setBusy) OperationOutcome.Succeeded => null, _ => ActionError, }; - setBusy(false); - NotifyChanged(); - if (outcome is OperationOutcome.Succeeded or OperationOutcome.Failed) + if (stops && outcome is OperationOutcome.Succeeded) + { + // Stopped for sure: show it now. Reading the status of a daemon that just stopped takes + // Windows seconds to fail, and the stale status would show Stop and Restart meanwhile. + Status = null; + Error = null; + } + else if (outcome is OperationOutcome.Succeeded or OperationOutcome.Failed) { await RefreshAsync(); } + setBusy(false); + NotifyChanged(); } public ShellLaunchResult Open(string target) => ReportShellResult(shell.Open(target)); diff --git a/windows/Flock.Tray/Settings/GeneralPane.xaml b/windows/Flock.Tray/Settings/GeneralPane.xaml index 1c0c3ce8..03cc4b2e 100644 --- a/windows/Flock.Tray/Settings/GeneralPane.xaml +++ b/windows/Flock.Tray/Settings/GeneralPane.xaml @@ -43,13 +43,18 @@ - - + + + + + + - diff --git a/windows/Flock.Tray/ViewModels/GeneralViewModel.cs b/windows/Flock.Tray/ViewModels/GeneralViewModel.cs index adbdbef1..4598fdf6 100644 --- a/windows/Flock.Tray/ViewModels/GeneralViewModel.cs +++ b/windows/Flock.Tray/ViewModels/GeneralViewModel.cs @@ -16,20 +16,25 @@ public sealed partial class GeneralViewModel : ObservableObject, IDisposable private readonly RuntimeService? model; private readonly Func restart; private readonly Func isRestarting; + private readonly Func stop; + private readonly Func isStopping; public GeneralViewModel(RuntimeService model) - : this(model.Configuration, model.RestartAsync, () => model.IsRestarting, () => model.Status, () => model.Error ?? model.ActionError) + : this(model.Configuration, model.RestartAsync, () => model.IsRestarting, () => model.Status, () => model.Error ?? model.ActionError, + model.StopAsync, () => model.IsStopping) { this.model = model; model.Changed += NotifyState; } public GeneralViewModel(ConfigurationService configuration, Func? restart = null, Func? isRestarting = null, - Func? status = null, Func? runtimeError = null) + Func? status = null, Func? runtimeError = null, Func? stop = null, Func? isStopping = null) { this.configuration = configuration; this.restart = restart ?? (() => Task.CompletedTask); this.isRestarting = isRestarting ?? (() => false); + this.stop = stop ?? (() => Task.CompletedTask); + this.isStopping = isStopping ?? (() => false); statusValue = status ?? (() => null); runtimeErrorValue = runtimeError ?? (() => null); configuration.Changed += NotifyState; @@ -41,8 +46,9 @@ public GeneralViewModel(ConfigurationService configuration, Func? restart public FlockConfig? Config => configuration.Current; public Status? Status => statusValue(); public string? Error => configuration.Error ?? runtimeErrorValue(); - public bool IsBusy => configuration.IsRunning || isRestarting(); + public bool IsBusy => configuration.IsRunning || isRestarting() || isStopping(); public bool IsRestarting => isRestarting(); + public bool IsStopping => isStopping(); public Visibility StatusVisibility => Status is null ? Visibility.Collapsed : Visibility.Visible; public string StatusText => Status?.IsDegraded == true ? "Needs attention" : "Running"; public Brush StatusBrush => Status?.IsDegraded == true ? Ui.Caution : Ui.Success; @@ -75,7 +81,12 @@ public GeneralViewModel(ConfigurationService configuration, Func? restart [RelayCommand(CanExecute = nameof(CanRestart))] private Task Restart() => restart(); - private bool CanRestart() => !isRestarting(); + private bool CanRestart() => !isRestarting() && !isStopping(); + + [RelayCommand(CanExecute = nameof(CanStop))] + private Task Stop() => stop(); + + private bool CanStop() => Status is not null && !isRestarting() && !isStopping(); [RelayCommand] private Task SetChannel(string? value) => value is null ? Task.CompletedTask : SetChannelAsync(value); @@ -102,6 +113,7 @@ private void NotifyState() OnPropertyChanged(nameof(Error)); OnPropertyChanged(nameof(IsBusy)); OnPropertyChanged(nameof(IsRestarting)); + OnPropertyChanged(nameof(IsStopping)); OnPropertyChanged(nameof(StatusVisibility)); OnPropertyChanged(nameof(StatusText)); OnPropertyChanged(nameof(StatusBrush)); @@ -118,5 +130,6 @@ private void NotifyState() OnPropertyChanged(nameof(BetaEnabled)); OnPropertyChanged(nameof(ExperimentalVisibility)); RestartCommand.NotifyCanExecuteChanged(); + StopCommand.NotifyCanExecuteChanged(); } } From 9a803782912ce6ba52ab157c680eff95f810328e Mon Sep 17 00:00:00 2001 From: Paulo Junior Date: Tue, 3 Nov 2026 18:26:31 -0300 Subject: [PATCH 6/7] feat: add Stop to the macOS panel and the Linux tray menu The macOS panel's footer has Stop next to Restart, and the Linux tray menu has Stop Flock under Restart Flock while the daemon runs, as the Windows tray has. Both run flock stop; afterwards Start Flock shows instead. Refs #92 --- docs/installation.md | 2 +- linux/src/main.rs | 1 + linux/src/tray.rs | 4 ++++ macos/Sources/FlockUI/PanelView.swift | 12 ++++++++---- 4 files changed, 14 insertions(+), 5 deletions(-) diff --git a/docs/installation.md b/docs/installation.md index d5b6c208..8d22db2f 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -101,7 +101,7 @@ Stop that program and run `flock restart`. Flock's daemon runs in the background, so your sites keep working when the app is closed. To free its memory when you're not working, stop it: every service (PHP, databases, mail, …) stops with it, and the databases shut down cleanly. :::app -Click **Stop Daemon** in **Settings → General**. On Windows, **Stop** at the bottom of the Flock panel and **Stop Flock** in the tray icon's menu do the same. While Flock is stopped, Settings shows **Start Flock**, which starts it again. +Click **Stop** at the bottom of the Flock panel, or **Stop Daemon** in **Settings → General**. On Windows and Linux, **Stop Flock** in the menu of Flock's icon does the same. While Flock is stopped, the panel and Settings show **Start Flock**, which starts it again. ::: :::cli diff --git a/linux/src/main.rs b/linux/src/main.rs index c5d833d6..b1475247 100644 --- a/linux/src/main.rs +++ b/linux/src/main.rs @@ -285,6 +285,7 @@ fn start_tray(app: &adw::Application, model: &Rc, windows: &Rc< m.change_settings(change, move |error| model.report(error)); } tray::Command::Restart => m.restart(), + tray::Command::Stop => m.stop(), tray::Command::Quit => app.quit(), tray::Command::Available(_) => {} }); diff --git a/linux/src/tray.rs b/linux/src/tray.rs index c1b6427f..0692727d 100644 --- a/linux/src/tray.rs +++ b/linux/src/tray.rs @@ -30,6 +30,7 @@ pub enum Command { /// Turns Xdebug or capturing dumps on or off. Change(SettingsChange), Restart, + Stop, Quit, /// The desktop's tray went away (the extension was turned off) or came back. Available(bool), @@ -252,6 +253,9 @@ impl ksni::Tray for FlockTray { item("Settings…", || Command::Open("settings")), item(if self.state.running { "Restart Flock" } else { "Start Flock" }, || Command::Restart), ]); + if self.state.running { + items.push(item("Stop Flock", || Command::Stop)); + } } items.push(item("Quit Flock", || Command::Quit)); items diff --git a/macos/Sources/FlockUI/PanelView.swift b/macos/Sources/FlockUI/PanelView.swift index 6d723ac8..d17cd100 100644 --- a/macos/Sources/FlockUI/PanelView.swift +++ b/macos/Sources/FlockUI/PanelView.swift @@ -69,7 +69,7 @@ struct PanelView: View { Text(model.headline).font(.caption).foregroundStyle(.secondary) } Spacer() - if model.isRestarting || model.isUpdating { + if model.isRestarting || model.isStopping || model.isUpdating { ProgressView().controlSize(.small) } } @@ -243,19 +243,23 @@ struct PanelView: View { Button("Start Flock") { Task { await model.restart() } } - .disabled(model.isRestarting || model.isUpdating) + .disabled(model.isRestarting || model.isStopping || model.isUpdating) } } } private var footer: some View { HStack { - // Restart and Settings need the daemon; before the setup, Set Up Flock is the way on. + // Restart, Stop and Settings need the daemon; before the setup, Set Up Flock is the way on. if !model.needsSetup { Button("Restart") { Task { await model.restart() } } - .disabled(model.isRestarting || model.isUpdating || !model.isRunning) + .disabled(model.isRestarting || model.isStopping || model.isUpdating || !model.isRunning) + Button("Stop") { + Task { await model.stop() } + } + .disabled(model.isRestarting || model.isStopping || model.isUpdating || !model.isRunning) Button("Settings…") { open(WindowID.settings) } From f7b7b12ac4184421c4f7ac3d76c37d6f50f4b24b Mon Sep 17 00:00:00 2001 From: Paulo Junior Date: Wed, 4 Nov 2026 10:14:39 -0300 Subject: [PATCH 7/7] fix(cli): make flock stop reliable when the daemon is slow or won't end - A daemon stopped while an API request still runs (an install) cuts it off and exits with code 0, instead of returning the shutdown timeout as an error, which made the Windows watchdog start it again. - On Windows, flock stop fails when the fallback could not end the daemon, instead of reporting it stopped. - On macOS, flock stop fails when launchd still has the agent loaded. - flock stop no longer gives up when the status read fails for another reason than the daemon not running: like flock restart, it only uses it for the pid. Refs #92 --- internal/cli/install_darwin.go | 7 +++++- internal/cli/install_windows.go | 15 ++++++++++++- internal/cli/stop.go | 14 +++++++----- internal/daemon/daemon.go | 13 +++++++++-- internal/daemon/daemon_test.go | 40 ++++++++++++++++++++++++++++++--- 5 files changed, 77 insertions(+), 12 deletions(-) diff --git a/internal/cli/install_darwin.go b/internal/cli/install_darwin.go index 4245299e..eacd95ff 100644 --- a/internal/cli/install_darwin.go +++ b/internal/cli/install_darwin.go @@ -499,7 +499,12 @@ func stopDaemon(context.Context, *api.Client, int) error { if !serviceInstalled() { return fmt.Errorf("the background daemon is not installed — run `flock install`") } - stopService("gui/" + strconv.Itoa(os.Getuid())) + domain := "gui/" + strconv.Itoa(os.Getuid()) + stopService(domain) + // stopService gives up after ten seconds, with the agent maybe still loaded. + if exec.Command("launchctl", "print", domain+"/"+launchLabel).Run() == nil { + return fmt.Errorf("launchd did not unload %s, so the daemon may still be running — see `flock logs daemon`", launchLabel) + } return nil } diff --git a/internal/cli/install_windows.go b/internal/cli/install_windows.go index 60b2052b..8bedecc7 100644 --- a/internal/cli/install_windows.go +++ b/internal/cli/install_windows.go @@ -549,6 +549,9 @@ func stopDaemon(ctx context.Context, client *api.Client, pid int) error { } stopDaemonProcess() waitDaemonGone(root) + if daemonAnswers(root) { + return fmt.Errorf("the daemon could not be ended and is still running — see `flock logs daemon`") + } return nil } @@ -558,7 +561,7 @@ func waitProcessExit(root string, pid int, timeout time.Duration) bool { if err != nil { // Gone already, or not ours to wait for: then at least wait for the API to go. waitDaemonGone(root) - return true + return !daemonAnswers(root) } defer windows.CloseHandle(p) event, err := windows.WaitForSingleObject(p, uint32(timeout.Milliseconds())) @@ -595,6 +598,16 @@ func waitDaemonGone(root string) { } } +// daemonAnswers reports whether a daemon still answers on the socket. +func daemonAnswers(root string) bool { + conn, err := net.DialTimeout("unix", paths.Socket(root), 500*time.Millisecond) + if err != nil { + return false + } + _ = conn.Close() + return true +} + // privilegedFirst: `flock install` runs ensurePrivileged before it replaces anything, so declining // the UAC prompt leaves the installed version as it was. The installer runs it on its own // (--privileged-only) before it replaces the app, so an update that isn't allowed doesn't happen. diff --git a/internal/cli/stop.go b/internal/cli/stop.go index 154151fc..3405f267 100644 --- a/internal/cli/stop.go +++ b/internal/cli/stop.go @@ -21,18 +21,22 @@ func newStopCommand() *cobra.Command { return err } - st, err := s.client().Status(cmd.Context()) - if err != nil && !errors.Is(err, api.ErrDaemonNotRunning) { + // Only to know the pid, as for `flock restart`: a daemon too busy to answer is stopped all the same. + st, statusErr := s.client().Status(cmd.Context()) + if err := cmd.Context().Err(); err != nil { return err } // Also when the daemon doesn't answer: the service may be about to start it again. if err := stopDaemon(cmd.Context(), s.client(), st.PID); err != nil { return err } - if st.PID == 0 { + // Stopping doesn't change the service: it still starts the daemon at login (Linux: with the computer). + switch { + case errors.Is(statusErr, api.ErrDaemonNotRunning): cmd.Println("not running") - } else { - // Stopping doesn't change the service: it still starts the daemon at login (Linux: with the computer). + case st.PID == 0: + cmd.Printf("stopped; it starts again with `flock start`, or %s\n", serviceStarts) + default: cmd.Printf("stopped (pid %d); it starts again with `flock start`, or %s\n", st.PID, serviceStarts) } return nil diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index 1369b805..5caad74f 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -51,6 +51,9 @@ type Config struct { // retryInterval is how often a degraded daemon tries to start what failed. var retryInterval = 15 * time.Second +// apiShutdownTimeout is how long a stopping daemon lets API requests finish. +var apiShutdownTimeout = 5 * time.Second + // trustInBrowsers adds the CA to the browsers' own trust stores (Linux). Tests replace it: it // writes to the user's home. var trustInBrowsers = certs.TrustInBrowsers @@ -400,10 +403,16 @@ loop: } } - shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + shutdownCtx, cancel := context.WithTimeout(context.Background(), apiShutdownTimeout) defer cancel() - return apiServer.Shutdown(shutdownCtx) + if err := apiServer.Shutdown(shutdownCtx); !errors.Is(err, context.DeadlineExceeded) { + return err + } + // A request still running (an install) is cut off: returning an error would make the Windows + // watchdog start a daemon again that `flock stop` stopped. + _ = apiServer.Close() + return nil } // postgresExtensionFiles fingerprints the active PostgreSQL major's custom extensions folder, so diff --git a/internal/daemon/daemon_test.go b/internal/daemon/daemon_test.go index b6d84bad..addd8c3b 100644 --- a/internal/daemon/daemon_test.go +++ b/internal/daemon/daemon_test.go @@ -253,8 +253,9 @@ func TestRefusesToStartTwice(t *testing.T) { } } -func TestStopEndsRun(t *testing.T) { - root, _ := testRoot(t, nil) +// runUntilStopped runs a daemon that only Stop ends, and returns its client and Run's result. +func runUntilStopped(t *testing.T, root string) (*api.Client, chan error) { + t.Helper() done := make(chan error, 1) go func() { done <- Run(context.Background(), Config{Root: root, Version: "dev"}) }() @@ -266,24 +267,57 @@ func TestStopEndsRun(t *testing.T) { } time.Sleep(20 * time.Millisecond) } + return client, done +} + +// stopAndWait stops the daemon and wants Run to return nil: the Windows watchdog restarts a +// daemon that exits with an error. +func stopAndWait(t *testing.T, client *api.Client, done chan error) { + t.Helper() if err := client.Stop(context.Background()); err != nil { t.Fatal(err) } select { case err := <-done: - // nil: the Windows watchdog only restarts a daemon that exits with an error. if err != nil { t.Fatalf("Run returned %v", err) } case <-time.After(10 * time.Second): t.Fatal("daemon did not stop") } +} + +func TestStopEndsRun(t *testing.T) { + root, _ := testRoot(t, nil) + client, done := runUntilStopped(t, root) + + stopAndWait(t, client, done) if _, err := client.Status(context.Background()); !errors.Is(err, api.ErrDaemonNotRunning) { t.Fatalf("want ErrDaemonNotRunning after stop, got %v", err) } } +func TestStopEndsRunWhileARequestIsStillRunning(t *testing.T) { + apiShutdownTimeout = 100 * time.Millisecond + t.Cleanup(func() { apiShutdownTimeout = 5 * time.Second }) + root, _ := testRoot(t, nil) + client, done := runUntilStopped(t, root) + + // A request the daemon is still reading when it stops, as an install would still be running. + conn, err := net.Dial("unix", paths.Socket(root)) + if err != nil { + t.Fatal(err) + } + defer conn.Close() + if _, err := conn.Write([]byte("GET /v1/status HTTP/1.1\r\nHost: flockd\r\n")); err != nil { + t.Fatal(err) + } + time.Sleep(50 * time.Millisecond) + + stopAndWait(t, client, done) +} + func TestRemovesStaleSocket(t *testing.T) { root, _ := testRoot(t, nil) if err := os.WriteFile(paths.Socket(root), nil, 0o600); err != nil {