diff --git a/docs/cli.md b/docs/cli.md index e6e64bf6..4abe6170 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/docs/installation.md b/docs/installation.md index 762f599f..8d22db2f 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 +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 +```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/internal/api/api.go b/internal/api/api.go index f95cc330..88625464 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -230,6 +230,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 @@ -286,6 +288,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 { @@ -376,6 +382,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 67973648..81a467a4 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..eacd95ff 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,11 +493,44 @@ func appDirs() []string { } } -// restartService makes launchd kill and relaunch the daemon. +// 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`") + } + 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 +} + +// 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, 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) 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..8bedecc7 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,74 @@ 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) + if daemonAnswers(root) { + return fmt.Errorf("the daemon could not be ended and is still running — see `flock logs daemon`") + } + 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 !daemonAnswers(root) + } + 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. @@ -528,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/root.go b/internal/cli/root.go index ac2a5ba0..0b809545 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(), newProxyCommand(), newUnproxyCommand(), newProxiesCommand(), 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..3405f267 --- /dev/null +++ b/internal/cli/stop.go @@ -0,0 +1,84 @@ +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 + } + + // 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 + } + // 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") + 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 + }, + } +} + +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 b683286c..1cb170d4 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 @@ -109,8 +112,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 { @@ -281,7 +289,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) @@ -393,10 +405,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 0645658e..addd8c3b 100644 --- a/internal/daemon/daemon_test.go +++ b/internal/daemon/daemon_test.go @@ -253,6 +253,71 @@ func TestRefusesToStartTwice(t *testing.T) { } } +// 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"}) }() + 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) + } + 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: + 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 { 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/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/tray.rs b/linux/src/tray.rs index a861c32c..45676a79 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), @@ -266,6 +267,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/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/PanelView.swift b/macos/Sources/FlockUI/PanelView.swift index 51646dd2..0170a122 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) } } @@ -245,19 +245,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) } 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/App.xaml.cs b/windows/Flock.Tray/App.xaml.cs index cfbfb561..ef0a9015 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"]; + ? ["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); @@ -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..48e9c2cc 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: 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, stops: true); + + private async Task RunLifecycleAsync(string command, Action setBusy, bool stops) { 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,12 +211,19 @@ public async Task RestartAsync() OperationOutcome.Succeeded => null, _ => ActionError, }; - IsRestarting = 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/PanelWindow.xaml b/windows/Flock.Tray/PanelWindow.xaml index 36e85e20..dd001813 100644 --- a/windows/Flock.Tray/PanelWindow.xaml +++ b/windows/Flock.Tray/PanelWindow.xaml @@ -356,9 +356,12 @@ + - + 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(); } } diff --git a/windows/Flock.Tray/ViewModels/PanelViewModel.cs b/windows/Flock.Tray/ViewModels/PanelViewModel.cs index 22d8675d..69c77fe5 100644 --- a/windows/Flock.Tray/ViewModels/PanelViewModel.cs +++ b/windows/Flock.Tray/ViewModels/PanelViewModel.cs @@ -29,7 +29,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)); @@ -73,6 +74,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; @@ -90,7 +92,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; @@ -104,8 +106,9 @@ 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; } = []; /// Proxies (`flock proxy`), listed after the sites. @@ -113,6 +116,7 @@ public bool Dumps 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; } @@ -191,6 +195,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)); @@ -224,7 +229,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