diff --git a/docs/ai.md b/docs/ai.md index 39cee4f4..1930a88f 100644 --- a/docs/ai.md +++ b/docs/ai.md @@ -46,7 +46,7 @@ These tools only read, so most clients run them without asking: | Tool | What it does | | --- | --- | | `status` | Whether Flock runs, the default PHP and Node.js versions, and any problems | -| `site_info` | A site's URLs, folder, HTTPS, PHP and Node.js versions (and where they come from), and its server | +| `site_info` | A site's URLs, folder, HTTPS, PHP and Node.js versions (and where they come from), its server, and its Reverb server with the `.env` lines it needs | | `php_extensions` | The PHP extensions a site requires or uses, and whether Flock's PHP has each | | `list_sites` | Every site with its URL, folder and PHP version | | `list_worktrees` | The [git worktrees](worktrees.md) Flock serves: their branch, URL, folder, setup and database | @@ -69,7 +69,7 @@ These tools change things, so clients ask you first: | `link_site` | Serve a project as a new site | | `proxy_site` | Proxy `.test` to a local service, e.g. a dashboard or a Docker container, optionally with the command that starts its server | | `proxy_server` | Start or stop the server of a proxy that has a command | -| `update_site` | Turn HTTPS on or off, isolate, unisolate, pin or unpin a version, add a domain, switch between FrankenPHP (with or without Octane) and PHP-FPM, serve it as a PHP site or a Node.js app, or restart or stop it | +| `update_site` | Turn HTTPS on or off, isolate, unisolate, pin or unpin a version, add a domain, switch between FrankenPHP (with or without Octane) and PHP-FPM, serve it as a PHP site or a Node.js app, run, stop or restart its Reverb server, or restart or stop it | | `set_debugging` | Turn Xdebug or dump capture on or off | | `create_database` | Create a database in PostgreSQL, MySQL or MariaDB | | `create_worktree` | Create a [git worktree](worktrees.md) of a site on a branch (or one Flock names), at its own URL, and wait while Flock sets it up | diff --git a/docs/cli.md b/docs/cli.md index 09b33de8..5504891d 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -73,6 +73,10 @@ This page lists the `flock` commands. The other pages show where to find each ac | `flock frankenphp list` | List FrankenPHP builds, installed and available | | `flock frankenphp install ` | Install or update FrankenPHP for a PHP version (`--from ` for a local package) | | `flock frankenphp uninstall ` | Remove FrankenPHP for a PHP version | +| `flock reverb [site]` | Show a site's [Reverb](php.md#laravel-reverb) server and the `.env` lines its app needs | +| `flock reverb on [site]` | Run a Laravel app's Reverb server, reached at `wss://.test/app` | +| `flock reverb off [site]` | Stop a site's Reverb server | +| `flock reverb restart [site]` | Restart a site's Reverb server, e.g. to load changed code | ## Services diff --git a/docs/introduction.md b/docs/introduction.md index 8bac2456..ed410ea6 100644 --- a/docs/introduction.md +++ b/docs/introduction.md @@ -14,7 +14,7 @@ Flock is a free, open-source local development environment for Laravel on **Wind Flock is a single `flock` binary that runs as a small background service (the *daemon*): -- An embedded [Caddy](https://caddyserver.com) web server listens on ports 80 and 443 and passes PHP requests to PHP-FPM (macOS and Linux) or a pool of `php-cgi` workers (Windows). A site can also run on [FrankenPHP](php.md#frankenphp), with Laravel Octane's worker mode if you like. +- An embedded [Caddy](https://caddyserver.com) web server listens on ports 80 and 443 and passes PHP requests to PHP-FPM (macOS and Linux) or a pool of `php-cgi` workers (Windows). A site can also run on [FrankenPHP](php.md#frankenphp), with Laravel Octane's worker mode if you like. For a Laravel app, Flock can run its [Reverb](php.md#laravel-reverb) server, reached at the site's own address. - An embedded DNS server answers for `*.test`, so new sites work immediately without editing your hosts file. - A local certificate authority issues HTTPS certificates. Its root is restricted to your local TLD, so it can't be used to impersonate real websites. - Small **shims** for `php`, `composer`, `node`, `npm`, `npx` and the database clients sit on your PATH and run the version that the current project uses. diff --git a/docs/php.md b/docs/php.md index e6368f36..840bc49c 100644 --- a/docs/php.md +++ b/docs/php.md @@ -132,6 +132,40 @@ flock frankenphp install 8.4 # install or update the build for a PHP vers Run them without a site name inside the project folder. ::: +## Laravel Reverb + +Flock can run a Laravel app's [Reverb](https://reverb.laravel.com) server for its site. Browsers reach it at the site's own address, `wss://acme.test/app`, through Flock's web server, so there's no port to open and no separate certificate. Flock runs `php artisan reverb:start` in the project with the site's PHP version, on a loopback port of its own. The project needs Laravel Reverb (`php artisan install:broadcasting --reverb`). + +Only Reverb's traffic goes to it: WebSocket connections under `/app/` and the signed requests Laravel broadcasts through under `/apps/`. Every other request on those paths stays the app's, so a panel at `/app` keeps working. This needs Reverb's default paths, so leave `REVERB_SERVER_PATH` unset. + +The app has to send its broadcasts and its Echo client to the site rather than to Reverb's own port. Flock shows the `.env` lines that do this, but doesn't change the project's `.env` itself: + +```env +REVERB_HOST=acme.test +REVERB_PORT=443 +REVERB_SCHEME=https +``` + +On a site served over HTTP only, they are `REVERB_PORT=80` and `REVERB_SCHEME=http`. `install:broadcasting --reverb` writes the `VITE_REVERB_*` variables as references to these, so the Echo client follows them. Rebuild the front end (`npm run build`, or restart `npm run dev`) after changing them. + +Restart the Reverb server to load changed code. It restarts on its own, so the site's FrankenPHP keeps running, and restarting FrankenPHP keeps Reverb's connections. Reverb's output goes to the `reverb-acme` [log](logs.md). + +:::app +In **Settings → Sites**, select the site and turn on **Run Reverb** in its **Reverb** section. It shows the `.env` lines with a **Copy** button, Reverb's state and output, and **Restart**. +::: + +:::cli +```bash +flock reverb on acme # run the site's Reverb server; prints the .env lines +flock reverb acme # its state and the .env lines +flock reverb restart acme # load changed code +flock reverb off acme # stop it +flock logs reverb-acme # its output +``` + +Run them without a site name inside the project folder. `flock sites` marks a site with Reverb on as `+ Reverb`. +::: + ## Composer `composer` is included and runs with the PHP version of the current project, so `composer install` always matches the version the browser uses. If the project's PHP version isn't installed yet, `php` and `composer` offer to install it the first time you run them. diff --git a/internal/api/api.go b/internal/api/api.go index e870b7ea..905ace79 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -196,8 +196,12 @@ type SiteStatus struct { // FrankenPHP is the state of the site's FrankenPHP process, when Server is "frankenphp". Until // it runs, the site is served through FastCGI. FrankenPHP *SiteFrankenPHP `json:"frankenphp,omitempty"` + // Reverb is the state of the site's Laravel Reverb server, when it's turned on. ReverbAvailable + // is set when the project has Laravel Reverb installed, so it can be turned on. + Reverb *SiteReverb `json:"reverb,omitempty"` + ReverbAvailable bool `json:"reverb_available"` // Type is what the site is: "php" or "node" (see config.Site.Type). A Node.js site has - // NodeServer instead of the PHP settings (PHP, Root, Server, FrankenPHP). + // NodeServer instead of the PHP settings (PHP, Root, Server, FrankenPHP, Reverb). Type string `json:"type"` NodeServer *SiteNodeServer `json:"node_server,omitempty"` // Boost is whether the project uses Laravel Boost and has Flock's guideline (see package boost): @@ -244,6 +248,22 @@ type SiteFrankenPHP struct { Log string `json:"log"` // name for /v1/logs/services/{name} } +// Reverb states of a site. +const ( + ReverbRunning = "running" + ReverbFailed = "failed" // see Problem + ReverbUnavailable = "unavailable" // the project has no Laravel Reverb; see Problem +) + +// SiteReverb is a site's Laravel Reverb server. +type SiteReverb struct { + State string `json:"state"` + Port int `json:"port"` // loopback port the web server proxies to + Env []string `json:"env,omitempty"` // the app's .env lines for reaching it through the web server + Problem string `json:"problem,omitempty"` + Log string `json:"log"` // name for /v1/logs/services/{name} +} + // SiteDomain is one of a site's extra domains. type SiteDomain struct { Entry string `json:"entry"` // as stored and passed to the API: "tenant1" or "my-app-2.test" @@ -301,8 +321,10 @@ type Backend interface { FrankenPHP() *frankenphp.Manager // Binaries is the package manifest of the configured channel (nil when there is none). Binaries() *binaries.Catalog - // RestartSite restarts a FrankenPHP site's process, e.g. so Octane's workers load changed code. + // RestartSite restarts a FrankenPHP site's process or a Node.js site's dev server, e.g. so it + // loads changed code; RestartReverb does the same for a site's Reverb server. RestartSite(name string) error + RestartReverb(name string) error // StopSite puts a Node.js site's dev server to sleep until the site's next request. StopSite(name string) error // StartProxy and StopProxy run a proxy's command and stop its server (see config.Proxy.Command). @@ -427,10 +449,11 @@ func (c *Client) Status(ctx context.Context) (Status, error) { return status, err } -// Reload asks the daemon to apply config.json and returns the resulting status. +// Reload asks the daemon to apply config.json and returns the resulting status. It may start a +// site's FrankenPHP or Reverb and wait for it (Octane up to 20 s), so it gets UpdateSite's minute. func (c *Client) Reload(ctx context.Context) (Status, error) { var status Status - err := c.do(ctx, http.MethodPost, "/v1/reload", &status) + err := c.timeout(time.Minute).do(ctx, http.MethodPost, "/v1/reload", &status) return status, err } diff --git a/internal/api/api_test.go b/internal/api/api_test.go index a5e6b91b..eac51341 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -59,6 +59,7 @@ func (f *fakeBackend) Catalog() *catalog.Manager { return f.catalo func (f *fakeBackend) FrankenPHP() *frankenphp.Manager { return f.frankenphp } func (f *fakeBackend) Binaries() *binaries.Catalog { return f.binaries } func (f *fakeBackend) RestartSite(string) error { return nil } +func (f *fakeBackend) RestartReverb(string) error { return nil } func (f *fakeBackend) StopSite(string) error { return nil } func (f *fakeBackend) Shares() *share.Manager { return nil } func (f *fakeBackend) StartShare(string, []string, bool) error { return nil } @@ -457,6 +458,43 @@ func TestSitesSwitchToFrankenPHPAndBack(t *testing.T) { } } +func TestSitesTurnReverbOnAndOff(t *testing.T) { + root := t.TempDir() + b := &fakeBackend{root: root} + srv := serve(t, b, "") + app, plain := t.TempDir(), t.TempDir() + for _, file := range []string{filepath.Join(app, "artisan"), filepath.Join(app, "vendor", "laravel", "reverb", "composer.json")} { + if err := os.MkdirAll(filepath.Dir(file), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(file, []byte("{}"), 0o644); err != nil { + t.Fatal(err) + } + } + cfg := b.config(t) + cfg.Upsert(config.Site{Name: "app", Path: app}) + cfg.Upsert(config.Site{Name: "plain", Path: plain}) + if err := cfg.Save(root); err != nil { + t.Fatal(err) + } + + if resp, body := call(t, "PATCH", srv.URL+"/v1/sites/app", "", `{"reverb":true}`); resp.StatusCode != http.StatusOK { + t.Fatalf("on: %d %s", resp.StatusCode, body) + } + if site, _ := b.config(t).FindSite("app"); !site.Reverb || site.ReverbPort != config.FirstSitePort { + t.Fatalf("site: %+v", site) + } + if resp, body := call(t, "PATCH", srv.URL+"/v1/sites/app", "", `{"reverb":false}`); resp.StatusCode != http.StatusOK { + t.Fatalf("off: %d %s", resp.StatusCode, body) + } + if site, _ := b.config(t).FindSite("app"); site.Reverb || site.ReverbPort != config.FirstSitePort { + t.Fatalf("site: %+v", site) + } + if resp, body := call(t, "PATCH", srv.URL+"/v1/sites/plain", "", `{"reverb":true}`); resp.StatusCode != http.StatusBadRequest || !strings.Contains(body, "install:broadcasting --reverb") { + t.Fatalf("without Reverb: %d %s", resp.StatusCode, body) + } +} + // jsonString quotes s for a JSON body (Windows paths have backslashes). func jsonString(s string) string { b, _ := json.Marshal(s) diff --git a/internal/api/configapi.go b/internal/api/configapi.go index 67ff858f..6c4dd89a 100644 --- a/internal/api/configapi.go +++ b/internal/api/configapi.go @@ -22,6 +22,7 @@ import ( "flock/internal/php" "flock/internal/ports" "flock/internal/project" + "flock/internal/reverb" ) // Service is one background service as shown in the Services settings. @@ -282,6 +283,7 @@ func registerConfig(mux *http.ServeMux, b Backend) { // Server is "fastcgi" or "frankenphp"; Worker "" (classic) or "octane" (FrankenPHP's worker mode). Server *string `json:"server"` Worker *string `json:"worker"` + Reverb *bool `json:"reverb"` // Root is the document root on this machine, relative to the project ("" lets the // project's .flockrc or detection decide again). Root *string `json:"root"` @@ -340,6 +342,12 @@ func registerConfig(mux *http.ServeMux, b Backend) { return err } } + if body.Reverb != nil { + if *body.Reverb && !reverb.Installed(site.Path) { + return invalid("Laravel Reverb isn't installed in %s — run `php artisan install:broadcasting --reverb`", site.Path) + } + cfg.SetReverb(&site, *body.Reverb) + } // A parked site becomes linked so its settings can be stored. site.Parked = false if err := cfg.Upsert(site); err != nil { @@ -633,6 +641,7 @@ type SiteChange struct { Node *string `json:"node,omitempty"` Server *string `json:"server,omitempty"` Worker *string `json:"worker,omitempty"` + Reverb *bool `json:"reverb,omitempty"` Root *string `json:"root,omitempty"` } @@ -715,7 +724,8 @@ func (c *Client) RemoveProxy(ctx context.Context, name string) (Status, error) { return out, err } -// UpdateSite changes a site's settings. Switching servers can take a while (FrankenPHP starts). +// UpdateSite changes a site's settings. Switching servers or turning on Reverb can take a while +// (the process starts). func (c *Client) UpdateSite(ctx context.Context, name string, change SiteChange) (Status, error) { var out Status err := c.timeout(time.Minute).send(ctx, http.MethodPatch, "/v1/sites/"+url.PathEscape(name), change, &out) diff --git a/internal/api/frankenphpapi.go b/internal/api/frankenphpapi.go index b643b44a..28a44ded 100644 --- a/internal/api/frankenphpapi.go +++ b/internal/api/frankenphpapi.go @@ -106,6 +106,13 @@ func registerFrankenPHP(mux *http.ServeMux, b Backend) { } writeJSON(w, http.StatusOK, b.Status()) }) + mux.HandleFunc("POST /v1/sites/{name}/reverb/restart", func(w http.ResponseWriter, r *http.Request) { + if err := b.RestartReverb(r.PathValue("name")); err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + writeJSON(w, http.StatusOK, b.Status()) + }) mux.HandleFunc("POST /v1/sites/{name}/stop", func(w http.ResponseWriter, r *http.Request) { if err := b.StopSite(r.PathValue("name")); err != nil { writeError(w, statusFor(err), err) @@ -156,3 +163,10 @@ func (c *Client) RestartSite(ctx context.Context, name string) (Status, error) { err := c.timeout(time.Minute).do(ctx, http.MethodPost, "/v1/sites/"+name+"/restart", &out) return out, err } + +// RestartReverb restarts a site's Reverb server. +func (c *Client) RestartReverb(ctx context.Context, name string) (Status, error) { + var out Status + err := c.timeout(time.Minute).do(ctx, http.MethodPost, "/v1/sites/"+url.PathEscape(name)+"/reverb/restart", &out) + return out, err +} diff --git a/internal/cli/reverb.go b/internal/cli/reverb.go new file mode 100644 index 00000000..df20a450 --- /dev/null +++ b/internal/cli/reverb.go @@ -0,0 +1,130 @@ +package cli + +import ( + "errors" + "strings" + + "github.com/spf13/cobra" + + "flock/internal/api" + "flock/internal/reverb" +) + +func newReverbCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "reverb", + Short: "Run a Laravel app's Reverb server, reachable at wss://.test/app", + Long: `Flock runs ` + "`php artisan reverb:start`" + ` for the site with its PHP version, on a loopback port, and its +web server sends the site's WebSocket connections (/app/...) and broadcasts (/apps/...) there. +Browsers connect to the site's own address, over HTTPS when the site is secured; the app's .env +needs the REVERB_HOST, REVERB_PORT and REVERB_SCHEME lines ` + "`flock reverb`" + ` shows. + + flock reverb [site] show the site's Reverb server and its .env lines + flock reverb on [site] run it (the project needs ` + "`php artisan install:broadcasting --reverb`" + `) + flock reverb off [site] stop it + flock reverb restart [site] restart it, e.g. to load changed code`, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + s, err := load() + if err != nil { + return err + } + site, err := siteArg(s, args) + if err != nil { + return err + } + return showReverb(cmd, s, site.Name) + }, + } + + toggle := func(on bool) func(*cobra.Command, []string) error { + return func(cmd *cobra.Command, args []string) error { + s, err := load() + if err != nil { + return err + } + site, err := siteArg(s, args) + if err != nil { + return err + } + if on && !reverb.Installed(site.Path) { + return errors.New("Laravel Reverb isn't installed in " + site.Path + " — run `php artisan install:broadcasting --reverb` first") + } + s.cfg.SetReverb(&site, on) + if err := s.cfg.Upsert(site); err != nil { + return err + } + if err := s.commit(cmd.Context(), cmd.ErrOrStderr()); err != nil { + return err + } + if !on { + cmd.Printf("stopped %s's Reverb server\n", site.Name) + return nil + } + // commit has already said that a stopped daemon applies the change when it starts. + if err := showReverb(cmd, s, site.Name); !errors.Is(err, api.ErrDaemonNotRunning) { + return err + } + return nil + } + } + on := &cobra.Command{Use: "on [site]", Short: "Run a site's Reverb server", Args: cobra.MaximumNArgs(1), RunE: toggle(true)} + off := &cobra.Command{Use: "off [site]", Short: "Stop a site's Reverb server", Args: cobra.MaximumNArgs(1), RunE: toggle(false)} + + restart := &cobra.Command{ + Use: "restart [site]", + Short: "Restart a site's Reverb server, e.g. to load changed code", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + s, err := load() + if err != nil { + return err + } + site, err := siteArg(s, args) + if err != nil { + return err + } + if !site.Reverb { + return errors.New("Reverb is off for " + site.Name + " — `flock reverb on` runs it") + } + st, err := s.client().RestartReverb(cmd.Context(), site.Name) + if err != nil { + return err + } + printReverb(cmd, st, site.Name) + return nil + }, + } + + cmd.AddCommand(on, off, restart) + return cmd +} + +// showReverb prints the site's Reverb server as the daemon reports it. +func showReverb(cmd *cobra.Command, s *state, name string) error { + st, err := s.client().Status(cmd.Context()) + if err != nil { + return err + } + printReverb(cmd, st, name) + return nil +} + +func printReverb(cmd *cobra.Command, st api.Status, name string) { + for _, site := range st.Sites { + if site.Name != name { + continue + } + r := site.Reverb + switch { + case r == nil: + cmd.Printf("Reverb is off for %s — `flock reverb on` runs it\n", name) + return + case r.State == api.ReverbRunning: + cmd.Printf("%s's Reverb server is running, log: `flock logs %s`\n", name, r.Log) + default: + cmd.Printf("%s's Reverb server isn't running: %s\n", name, r.Problem) + } + cmd.Printf("in the app's .env:\n %s\n", strings.Join(r.Env, "\n ")) + } +} diff --git a/internal/cli/reverb_test.go b/internal/cli/reverb_test.go new file mode 100644 index 00000000..3d514d97 --- /dev/null +++ b/internal/cli/reverb_test.go @@ -0,0 +1,30 @@ +package cli + +import ( + "errors" + "io" + "testing" + + "flock/internal/api" + "flock/internal/config" +) + +func TestReverbStatusSaysWhenTheDaemonIsNotRunning(t *testing.T) { + root := t.TempDir() + t.Setenv("FLOCK_HOME", root) + cfg := config.Default() + cfg.Upsert(config.Site{Name: "app", Path: t.TempDir(), Reverb: true, ReverbPort: 21000}) + if err := cfg.Save(root); err != nil { + t.Fatal(err) + } + + cmd := newReverbCommand() + cmd.SetArgs([]string{"app"}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + + // It used to exit successfully without printing anything. + if err := cmd.Execute(); !errors.Is(err, api.ErrDaemonNotRunning) { + t.Fatalf("err = %v, want %v", err, api.ErrDaemonNotRunning) + } +} diff --git a/internal/cli/root.go b/internal/cli/root.go index 7e059bcb..610a3b26 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -28,7 +28,7 @@ func NewRootCommand() *cobra.Command { newSitesCommand(), newLinkCommand(), newUnlinkCommand(), newParkCommand(), newUnparkCommand(), newProxyCommand(), newUnproxyCommand(), newProxiesCommand(), newTypeCommand(), newSecureCommand(true), newSecureCommand(false), newDomainCommand(), newWorktreeCommand(), newShareCommand(), newUseCommand(), newIsolateCommand(), newUnisolateCommand(), newIsolatedCommand(), newPinCommand(), newUnpinCommand(), newRootCommand(), - newPHPCommand(), newNodeCommand(), newServicesCommand(), newXdebugCommand(), newDebugCommand(), newFrankenPHPCommand(), newDumpsCommand(), newEventsCommand(), newMailCommand(), + newPHPCommand(), newNodeCommand(), newServicesCommand(), newXdebugCommand(), newDebugCommand(), newFrankenPHPCommand(), newReverbCommand(), newDumpsCommand(), newEventsCommand(), newMailCommand(), newExecCommand(), newMCPCommand(), newBoostCommand(), newChannelCommand(), newExtCommand(), ) diff --git a/internal/cli/status.go b/internal/cli/status.go index 47b9be68..eba42d0a 100644 --- a/internal/cli/status.go +++ b/internal/cli/status.go @@ -127,6 +127,9 @@ func newSitesCommand() *cobra.Command { version += " (Octane)" } } + if site.Reverb != nil { + version += " + Reverb" + } if site.Type == config.TypeNode { version, root = "Node.js "+site.Node, "-" } diff --git a/internal/config/config.go b/internal/config/config.go index a0b74551..7459b0bd 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -148,6 +148,10 @@ type Site struct { // It is kept while the site runs on FastCGI, for switching back. Worker string `json:"worker,omitempty"` Port int `json:"port,omitempty"` // FrankenPHP's loopback port, assigned by SetServer + // Reverb runs the app's Laravel Reverb server on ReverbPort, and the web server sends the site's + // WebSocket and broadcast requests there. The port is kept while Reverb is off. + Reverb bool `json:"reverb,omitempty"` + ReverbPort int `json:"reverb_port,omitempty"` } // Servers and worker modes of a site (see Site.Server and Site.Worker). @@ -157,13 +161,17 @@ const ( WorkerOctane = "octane" ) -// FirstSitePort is where the loopback ports of FrankenPHP sites start. +// FirstSitePort is where the loopback ports of sites' FrankenPHP and Reverb processes start. const FirstSitePort = 21000 // SitePortID is how UsedPorts names the port of a FrankenPHP site: "frankenphp-", which is // also the name of its process and log. func SitePortID(site string) string { return "frankenphp-" + site } +// ReverbPortID is how UsedPorts names the port of a site's Reverb server: "reverb-", which +// is also the name of its process and log. +func ReverbPortID(site string) string { return "reverb-" + site } + // FrankenPHP reports whether the site runs on FrankenPHP. func (s Site) FrankenPHP() bool { return s.Kind() == TypePHP && s.Server == ServerFrankenPHP } @@ -185,33 +193,46 @@ func (c *Config) SetServer(site *Site, server, worker string) error { return fmt.Errorf("unknown worker mode %q (octane, or empty for classic)", worker) } site.Server, site.Worker, site.Parked = server, worker, false - if server == ServerFrankenPHP && (site.Port == 0 || c.portTaken(site.Port, site.Name)) { - site.Port = c.nextSitePort(site.Name) + if server == ServerFrankenPHP { + reverb := 0 + if site.Reverb { + reverb = site.ReverbPort + } + site.Port = c.sitePort(site.Port, SitePortID(site.Name), reverb) } return nil } -// portTaken reports whether a service or another site uses port. -func (c *Config) portTaken(port int, site string) bool { - if _, used := c.UsedPorts("")[port]; used { - for _, s := range c.Sites { - if s.Name == site && s.Port == port && s.FrankenPHP() { - return false - } +// SetReverb turns the site's Reverb server on or off, giving it a loopback port the first time. +// The site becomes linked so the setting can be stored; the caller saves it with Upsert. +func (c *Config) SetReverb(site *Site, on bool) { + site.Reverb, site.Parked = on, false + if on { + frankenphp := 0 + if site.FrankenPHP() { + frankenphp = site.Port } - return true + site.ReverbPort = c.sitePort(site.ReverbPort, ReverbPortID(site.Name), frankenphp) } - return false } -func (c *Config) nextSitePort(site string) int { - port := FirstSitePort - for c.portTaken(port, site) { - port++ +// sitePort keeps port for the process id when nothing else uses it, or finds the first free one. +// sibling is the site's other process's port, which may not be saved yet. +func (c *Config) sitePort(port int, id string, sibling int) int { + if port != 0 && port != sibling && !c.portTaken(port, id) { + return port + } + for port = FirstSitePort; port == sibling || c.portTaken(port, id); port++ { } return port } +// portTaken reports whether a service or a process other than id uses port. +func (c *Config) portTaken(port int, id string) bool { + other, used := c.UsedPorts("")[port] + return used && other != id +} + // Default returns the config used when none exists yet. It has no default PHP and Node.js: the // daemon installs the newest stable ones on the first run and makes them the defaults then. func Default() *Config { @@ -268,6 +289,9 @@ func (c *Config) UsedPorts(except string) map[int]string { if s.FrankenPHP() && s.Port != 0 { used[s.Port] = SitePortID(s.Name) } + if s.Reverb && s.ReverbPort != 0 { + used[s.ReverbPort] = ReverbPortID(s.Name) + } } return used } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 38bca3e3..9615b96b 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -112,6 +112,33 @@ func TestSetServerAssignsStablePortsAndValidates(t *testing.T) { t.Errorf("UsedPorts names b's port %q", got) } + // Reverb gets a port of its own, past b's, and keeps it while off. + cfg.SetReverb(&a, true) + if !a.Reverb || a.ReverbPort != FirstSitePort+3 { + t.Fatalf("reverb: %+v", a) + } + cfg.Upsert(a) + if got := cfg.UsedPorts("")[FirstSitePort+3]; got != "reverb-a" { + t.Errorf("UsedPorts names a's Reverb port %q", got) + } + cfg.SetReverb(&a, false) + cfg.Upsert(a) + if _, used := cfg.UsedPorts("")[FirstSitePort+3]; used || a.ReverbPort != FirstSitePort+3 { + t.Errorf("Reverb off: %+v", a) + } + cfg.SetReverb(&a, true) + if a.ReverbPort != FirstSitePort+3 { + t.Errorf("Reverb on again: %+v", a) + } + cfg.Upsert(a) + + // Both switched on before either is saved: the ports still differ. + c := Site{Name: "c", Path: "/srv/c"} + cfg.SetReverb(&c, true) + if err := cfg.SetServer(&c, ServerFrankenPHP, ""); err != nil || c.ReverbPort != FirstSitePort+4 || c.Port != FirstSitePort+5 { + t.Fatalf("c: %+v, %v", c, err) + } + if err := cfg.SetServer(&a, "swoole", ""); err == nil { t.Error("unknown server accepted") } diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index d0543b88..91b00ca5 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -36,6 +36,7 @@ import ( "flock/internal/ports" "flock/internal/postgres" "flock/internal/project" + "flock/internal/reverb" "flock/internal/share" "flock/internal/supervisor" "flock/internal/web" @@ -77,9 +78,11 @@ type daemon struct { php *php.Manager // binaries is the manifest of prebuilt packages for the configured channel, shared by the managers. binaries *binaries.Catalog - // frankenphp installs FrankenPHP builds; restarts counts RestartSite calls per site (see frankenphp.Site.Restart). - frankenphp *frankenphp.Manager - restarts map[string]int + // frankenphp installs FrankenPHP builds; restarts counts RestartSite calls per site (see + // frankenphp.Site.Restart), reverbRestarts RestartReverb calls (see reverb.Site.Restart). + frankenphp *frankenphp.Manager + restarts map[string]int + reverbRestarts map[string]int // pg manages the PostgreSQL versions, catalog the other services (MySQL, Valkey, Meilisearch, ...). pg *postgres.Manager catalog *catalog.Manager @@ -284,7 +287,8 @@ 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{}} + d := &daemon{root: cfg.Root, version: cfg.Version, startedAt: time.Now(), procs: supervisor.New(), leaves: certs.NewLeaves(), restarts: map[string]int{}, + reverbRestarts: map[string]int{}} defer d.shutdown() d.binaries = binaries.NewCatalog(binaries.DefaultSource(cfg.Root, cfg.Version), d.channel) @@ -420,7 +424,8 @@ var projectWatchInterval = 3 * time.Second // projectFiles fingerprints the sites, so folders added to or removed from a parked path count, // their project files (.flockrc, .nvmrc, .node-version, also in parent directories) by size and -// modification time, and which of the files that decide their document root exist. +// modification time, which of the files that decide their document root exist, and whether the +// project has Laravel Reverb, so installing it offers Reverb without another change. func (d *daemon) projectFiles() string { d.mu.Lock() cfg := d.cfg @@ -431,7 +436,7 @@ func (d *daemon) projectFiles() string { var b strings.Builder seen := map[string]bool{} for _, site := range cfg.AllSites() { - fmt.Fprintf(&b, "site %s %s\n", site.Name, site.Path) + fmt.Fprintf(&b, "site %s %s reverb=%t\n", site.Name, site.Path, reverb.Installed(site.Path)) for _, file := range project.Files(site.Path) { if seen[file] { continue @@ -675,6 +680,7 @@ func (d *daemon) reload() error { NodeShadowed: nodeVersion.Shadowed, Domains: []api.SiteDomain{}, } status.Server, status.Worker, status.OctaneAvailable = config.ServerFastCGI, site.Worker, frankenphp.HasOctane(site.Path) + status.ReverbAvailable = reverb.Installed(site.Path) if site.FrankenPHP() { status.Server = config.ServerFrankenPHP } @@ -702,7 +708,7 @@ func (d *daemon) reload() error { takenPorts[port] = true spec := d.nodeSpec(cfg, site, port, nodeVersion.Version) nodeSpecs[site.Name], nodePorts[site.Name] = spec, port - status.Server, status.Worker, status.OctaneAvailable, status.PHP = "", "", false, "" + status.Server, status.Worker, status.OctaneAvailable, status.ReverbAvailable, status.PHP = "", "", false, false, "" status.NodeServer = &api.SiteNodeServer{State: api.NodeSleeping, Port: port, Command: spec.Shell, Log: spec.Name, Problem: nodeSiteProblem(site)} if d.node.isAwake(site.Name) && !runningAsRoot() { d.procs.Ensure(spec) @@ -735,6 +741,7 @@ func (d *daemon) reload() error { // Its names get a page that says what's missing instead of the site (see web.Waiting). w := d.waitingFor(site, phpVersion, domain, aliases) status.Problem = waitingProblem(w) + status.Reverb = reverbBlocked(cfg, site, scheme, status.Problem) waiting = append(waiting, w) domains = append(append(domains, domain), served...) statuses = append(statuses, status) @@ -752,6 +759,7 @@ func (d *daemon) reload() error { if root.State == project.RootMissing { status.Problem = rootProblem(root) } + status.Reverb = reverbBlocked(cfg, site, scheme, "the site has no usable document root") rootless = append(rootless, r) status.URL = scheme + "://" + domain + portSuffix(scheme, cfg) domains = append(append(domains, domain), served...) @@ -788,6 +796,27 @@ func (d *daemon) reload() error { return err } } + if site.Reverb { + spec, rs := d.reverbSite(cfg, site, version, scheme, extraEnv) + if spec != nil { + restarted := d.procs.Ensure(*spec) + keep[spec.Name] = true + ws.Reverb = fmt.Sprintf("127.0.0.1:%d", site.ReverbPort) + wait := 300 * time.Millisecond + if restarted { + wait = 10 * time.Second // Reverb boots the app first + } + if php.WaitReady([]string{ws.Reverb}, wait) { + rs.State = api.ReverbRunning + } else { + rs.State, rs.Problem = api.ReverbFailed, fmt.Sprintf("Reverb did not start — see `flock logs %s`", spec.Name) + warnings = append(warnings, fmt.Sprintf("reverb: %s did not start — see `flock logs %s`", site.Name, spec.Name)) + } + } else if w := reverbPreflightWarning(site.Name, rs); w != "" { + warnings = append(warnings, w) + } + status.Reverb = &rs + } if cfg.Xdebug && cfg.XdebugBreakpoints { breakpoints[site.Path] = d.breakpoints.Has(site.Path) ws.Debugging = breakpoints[site.Path] && len(ws.DebugFPM) > 0 @@ -1085,6 +1114,9 @@ func serviceName(id string) string { case "postgres": return "PostgreSQL" } + if site, ok := strings.CutPrefix(id, "reverb-"); ok { + return site + "'s Reverb server" + } for _, k := range catalog.Kinds(runtime.GOOS) { if k.ID == id { return k.Name @@ -1213,12 +1245,19 @@ func (d *daemon) ReleasePHP(minor string) { d.reloadMu.Lock() defer d.reloadMu.Unlock() + d.procs.Retain(notUsingPHP(d.root, minor, d.procs.Paths())) +} + +// notUsingPHP marks which of the running processes (name → executable) don't run PHP minor: the +// dump server, the version's pools, and whatever was started with one of its binaries, such as a +// site's Reverb server. That is the version it runs, even when the project's pin changed since. +func notUsingPHP(root, minor string, running map[string]string) map[string]bool { + dir := php.Dir(root, minor) + string(filepath.Separator) keep := map[string]bool{} - for _, name := range d.procs.Running() { - usesPHP := name == "dump-server" || strings.HasPrefix(name, "php"+minor) - keep[name] = !usesPHP + for name, path := range running { + keep[name] = !(name == "dump-server" || strings.HasPrefix(name, "php"+minor) || strings.HasPrefix(path, dir)) } - d.procs.Retain(keep) + return keep } // Services implements api.Backend. diff --git a/internal/daemon/daemon_test.go b/internal/daemon/daemon_test.go index 950b8d19..a65f174e 100644 --- a/internal/daemon/daemon_test.go +++ b/internal/daemon/daemon_test.go @@ -208,6 +208,28 @@ func TestReloadReportsSitesWithMissingPHP(t *testing.T) { } } +func TestReloadSaysWhyAnEnabledReverbServerCantRun(t *testing.T) { + root, cfg := testRoot(t, nil) + client := startDaemon(t, root) + + cfg.Upsert(config.Site{Name: "app", Path: t.TempDir(), PHP: "7.0", Reverb: true, ReverbPort: freePort(t)}) + if err := cfg.Save(root); err != nil { + t.Fatal(err) + } + + status, err := client.Reload(context.Background()) + if err != nil { + t.Fatal(err) + } + if len(status.Sites) != 1 { + t.Fatalf("unexpected sites: %+v", status.Sites) + } + // Without it `flock reverb` read the missing status as "Reverb is off" and suggested turning it on. + if r := status.Sites[0].Reverb; r == nil || r.State != api.ReverbUnavailable || r.Problem != "PHP 7.0 is not installed" { + t.Fatalf("reverb status: %+v", r) + } +} + func TestTheFirstRunWaitsForTheDefaultsAndSaysSo(t *testing.T) { root, cfg := testRoot(t, func(cfg *config.Config) { cfg.DefaultPHP, cfg.DefaultNode = "", "" }) cfg.Upsert(config.Site{Name: "app", Path: t.TempDir()}) @@ -388,6 +410,22 @@ func TestProjectFilesFollowsTheConfiguredCaddyfile(t *testing.T) { } } +func TestProjectFilesNoticesReverbBeingInstalled(t *testing.T) { + site := t.TempDir() + d := daemon{cfg: &config.Config{Sites: []config.Site{{Name: "app", Path: site}}}} + before := d.projectFiles() + + // composer require laravel/reverb changes no file the fingerprint watches otherwise. + writeFile(t, filepath.Join(site, "artisan"), "") + if err := os.MkdirAll(filepath.Join(site, "vendor", "laravel", "reverb"), 0o755); err != nil { + t.Fatal(err) + } + writeFile(t, filepath.Join(site, "vendor", "laravel", "reverb", "composer.json"), "{}") + if d.projectFiles() == before { + t.Fatal("installing Reverb did not change the project fingerprint") + } +} + func TestSiteHandlersProblemsLeaveTheSiteServedWithoutThem(t *testing.T) { dir := t.TempDir() cfg := &config.Config{} diff --git a/internal/daemon/frankenphp.go b/internal/daemon/frankenphp.go index a15746db..c7b8aabc 100644 --- a/internal/daemon/frankenphp.go +++ b/internal/daemon/frankenphp.go @@ -76,6 +76,7 @@ func (d *daemon) frankenPHPSite(cfg *config.Config, site config.Site, minor, doc // RestartSite implements api.Backend: it restarts a FrankenPHP site's process, e.g. so Octane's // workers load changed code, or a Node.js site's dev server (starting it when it doesn't run). +// A site's Reverb server has RestartReverb. func (d *daemon) RestartSite(name string) error { d.mu.Lock() cfg := d.cfg diff --git a/internal/daemon/reverb.go b/internal/daemon/reverb.go new file mode 100644 index 00000000..900d302e --- /dev/null +++ b/internal/daemon/reverb.go @@ -0,0 +1,93 @@ +package daemon + +import ( + "fmt" + + "flock/internal/api" + "flock/internal/config" + "flock/internal/ports" + "flock/internal/reverb" + "flock/internal/supervisor" +) + +// reverbStatus is a site's Reverb server as status reports it, before its state is known. +func reverbStatus(cfg *config.Config, site config.Site, scheme string) api.SiteReverb { + port := cfg.Ports.HTTP + if scheme == "https" { + port = cfg.Ports.HTTPS + } + return api.SiteReverb{Port: site.ReverbPort, Log: config.ReverbPortID(site.Name), Env: reverb.Env(cfg.Domain(site), scheme, port)} +} + +// reverbBlocked is the status of an enabled Reverb server that can't run because the site itself +// can't be served (no PHP, no document root), or nil when Reverb is off. +func reverbBlocked(cfg *config.Config, site config.Site, scheme, problem string) *api.SiteReverb { + if !site.Reverb { + return nil + } + st := reverbStatus(cfg, site, scheme) + st.State, st.Problem = api.ReverbUnavailable, problem + return &st +} + +// reverbPreflightWarning is the warning for a Reverb server that couldn't be started at all (its +// port is busy, say), or "". Warnings make the daemon retry its reload, so Reverb starts once the +// port is free; without one an otherwise healthy daemon never tried again. +func reverbPreflightWarning(site string, st api.SiteReverb) string { + if st.State != api.ReverbFailed { + return "" + } + return fmt.Sprintf("reverb: %s: %s", site, st.Problem) +} + +// reverbSite prepares a site's Reverb server: its process, when it can run, and its state. +func (d *daemon) reverbSite(cfg *config.Config, site config.Site, minor, scheme string, extraEnv []string) (*supervisor.Spec, api.SiteReverb) { + name := config.ReverbPortID(site.Name) + st := reverbStatus(cfg, site, scheme) + fail := func(state, format string, args ...any) (*supervisor.Spec, api.SiteReverb) { + st.State, st.Problem = state, fmt.Sprintf(format, args...) + return nil, st + } + + if !reverb.Installed(site.Path) { + return fail(api.ReverbUnavailable, "Laravel Reverb isn't installed in %s — run `php artisan install:broadcasting --reverb`", site.Path) + } + // reverb:start boots the project's own code, which shouldn't run with root's rights. + if runningAsRoot() { + return fail(api.ReverbUnavailable, "Flock runs as root here, and doesn't run a project's Reverb server with root's rights") + } + if other, taken := cfg.UsedPorts("")[site.ReverbPort]; site.ReverbPort == 0 || taken && other != name { + return fail(api.ReverbFailed, "port %d is also configured for %s — turn Reverb off and on again for a new port", site.ReverbPort, serviceName(other)) + } + // Its own counter: restarting the site's FrankenPHP doesn't drop Reverb's connections. + d.mu.Lock() + restart := d.reverbRestarts[site.Name] + d.mu.Unlock() + spec := reverb.Spec(d.root, reverb.Site{Name: site.Name, Path: site.Path, Minor: minor, Port: site.ReverbPort, Env: extraEnv, Restart: restart}) + if !d.procUp(spec.Name) && !ports.Free("127.0.0.1", site.ReverbPort) { + return fail(api.ReverbFailed, "%s", ports.Describe(site.ReverbPort)) + } + return &spec, st +} + +// RestartReverb implements api.Backend: it restarts a site's Reverb server, e.g. so it loads +// changed code. The site's FrankenPHP keeps running. +func (d *daemon) RestartReverb(name string) error { + d.mu.Lock() + cfg := d.cfg + d.mu.Unlock() + if cfg == nil { + return fmt.Errorf("the daemon has no configuration yet") + } + site, ok := cfg.FindSite(name) + if !ok { + return fmt.Errorf("no site named %s", name) + } + if !site.Reverb { + return fmt.Errorf("Reverb is off for %s", name) + } + d.mu.Lock() + d.reverbRestarts[name]++ + d.mu.Unlock() + return d.reload() +} diff --git a/internal/daemon/reverb_test.go b/internal/daemon/reverb_test.go new file mode 100644 index 00000000..652fefe4 --- /dev/null +++ b/internal/daemon/reverb_test.go @@ -0,0 +1,131 @@ +package daemon + +import ( + "maps" + "net" + "os" + "path/filepath" + "slices" + "strings" + "testing" + + "flock/internal/api" + "flock/internal/config" + "flock/internal/php" + "flock/internal/supervisor" +) + +func TestReverbSiteRunsReverbStartOrSaysWhyNot(t *testing.T) { + d := &daemon{root: t.TempDir(), procs: supervisor.New(), restarts: map[string]int{"app": 5}, reverbRestarts: map[string]int{"app": 2}} + cfg := config.Default() + cfg.Ports.HTTPS = 8443 + app := t.TempDir() + cfg.Upsert(config.Site{Name: "app", Path: app, Secured: true, Reverb: true, ReverbPort: freePort(t)}) + site, _ := cfg.FindSite("app") + + // Not a Laravel app with Reverb yet. + if spec, st := d.reverbSite(cfg, site, "8.4", "https", nil); spec != nil || st.State != api.ReverbUnavailable || !strings.Contains(st.Problem, "install:broadcasting --reverb") { + t.Fatalf("without Reverb: %v %+v", spec, st) + } + + for _, file := range []string{"artisan", filepath.Join("vendor", "laravel", "reverb", "composer.json")} { + _ = os.MkdirAll(filepath.Dir(filepath.Join(app, file)), 0o755) + if err := os.WriteFile(filepath.Join(app, file), nil, 0o644); err != nil { + t.Fatal(err) + } + } + spec, st := d.reverbSite(cfg, site, "8.4", "https", []string{"VAR_DUMPER_FORMAT=server"}) + if spec == nil || st.Problem != "" || st.Log != "reverb-app" || st.Port != site.ReverbPort { + t.Fatalf("with Reverb: %v %+v", spec, st) + } + if want := []string{"REVERB_HOST=app.test", "REVERB_PORT=8443", "REVERB_SCHEME=https"}; !slices.Equal(st.Env, want) { + t.Errorf(".env lines: %v", st.Env) + } + args := strings.Join(spec.Args, " ") + if !strings.HasPrefix(args, "artisan reverb:start --host=127.0.0.1 --port=") || !strings.Contains(args, "--hostname=127.0.0.1") || spec.Dir != app { + t.Errorf("command: %s in %s", args, spec.Dir) + } + if !slices.Contains(spec.Env, "VAR_DUMPER_FORMAT=server") || !slices.Contains(spec.Env, "FLOCK_RESTART=2") || + !slices.ContainsFunc(spec.Env, func(e string) bool { return strings.HasPrefix(e, "FLOCK_PHP_VERSION=") }) { + t.Errorf("env: %v", spec.Env) + } + + // Another site's FrankenPHP configured on the same port. + cfg.Upsert(config.Site{Name: "other", Path: t.TempDir(), Server: config.ServerFrankenPHP, Port: site.ReverbPort}) + if spec, st := d.reverbSite(cfg, site, "8.4", "https", nil); spec != nil || st.State != api.ReverbFailed || !strings.Contains(st.Problem, "also configured") { + t.Fatalf("port conflict: %v %+v", spec, st) + } +} + +func TestReleasingAPHPVersionStopsTheReverbServersOnIt(t *testing.T) { + root := t.TempDir() + running := map[string]string{ + "php8.4": php.CGIBinary(root, "8.4"), "php8.4-xdebug": php.CGIBinary(root, "8.4"), "php8.3": php.CGIBinary(root, "8.3"), + "dump-server": php.Binary(root, "8.4"), + // app's Reverb was started on 8.4. Its pin may say 8.5 by now: it still runs 8.4's php. + "reverb-app": php.Binary(root, "8.4"), "reverb-old": php.Binary(root, "8.3"), + "frankenphp-off": filepath.Join(root, "frankenphp", "8.4", "frankenphp"), + } + + keep := notUsingPHP(root, "8.4", running) + + want := map[string]bool{"php8.4": false, "php8.4-xdebug": false, "php8.3": true, "dump-server": false, + "reverb-app": false, "reverb-old": true, "frankenphp-off": true} + if !maps.Equal(keep, want) { + t.Errorf("keep = %v, want %v", keep, want) + } + + // "" releases every version. + keep = notUsingPHP(root, "", running) + want = map[string]bool{"php8.4": false, "php8.4-xdebug": false, "php8.3": false, "dump-server": false, + "reverb-app": false, "reverb-old": false, "frankenphp-off": true} + if !maps.Equal(keep, want) { + t.Errorf("keep all = %v, want %v", keep, want) + } +} + +func TestAnEnabledReverbServerSaysWhyItCantRun(t *testing.T) { + cfg := config.Default() + cfg.Upsert(config.Site{Name: "app", Path: t.TempDir(), Reverb: true, ReverbPort: 21000}) + cfg.Upsert(config.Site{Name: "plain", Path: t.TempDir()}) + app, _ := cfg.FindSite("app") + plain, _ := cfg.FindSite("plain") + + st := reverbBlocked(cfg, app, "http", "PHP 8.4 is not installed") + if st == nil || st.State != api.ReverbUnavailable || st.Problem != "PHP 8.4 is not installed" || st.Log != "reverb-app" || len(st.Env) != 3 { + t.Fatalf("blocked: %+v", st) + } + if reverbBlocked(cfg, plain, "http", "anything") != nil { + t.Error("a site with Reverb off has no Reverb status") + } +} + +func TestABusyReverbPortIsAWarningSoTheReloadIsRetried(t *testing.T) { + d := &daemon{root: t.TempDir(), procs: supervisor.New(), restarts: map[string]int{}, reverbRestarts: map[string]int{}} + cfg := config.Default() + app := t.TempDir() + for _, file := range []string{"artisan", filepath.Join("vendor", "laravel", "reverb", "composer.json")} { + _ = os.MkdirAll(filepath.Dir(filepath.Join(app, file)), 0o755) + if err := os.WriteFile(filepath.Join(app, file), nil, 0o644); err != nil { + t.Fatal(err) + } + } + busy, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer busy.Close() + cfg.Upsert(config.Site{Name: "app", Path: app, Reverb: true, ReverbPort: busy.Addr().(*net.TCPAddr).Port}) + site, _ := cfg.FindSite("app") + + spec, st := d.reverbSite(cfg, site, "8.4", "http", nil) + if spec != nil || st.State != api.ReverbFailed { + t.Fatalf("busy port: %v %+v", spec, st) + } + if w := reverbPreflightWarning("app", st); !strings.HasPrefix(w, "reverb: app: ") { + t.Errorf("warning = %q", w) + } + if w := reverbPreflightWarning("app", api.SiteReverb{State: api.ReverbUnavailable, Problem: "not installed"}); w != "" { + t.Errorf("a project without Reverb isn't a warning: %q", w) + } +} diff --git a/internal/mcpserver/mcpserver_test.go b/internal/mcpserver/mcpserver_test.go index 3af6426f..a7f8caa1 100644 --- a/internal/mcpserver/mcpserver_test.go +++ b/internal/mcpserver/mcpserver_test.go @@ -192,6 +192,11 @@ func (f *fakeDaemon) RestartSite(_ context.Context, name string) (api.Status, er return f.status, nil } +func (f *fakeDaemon) RestartReverb(_ context.Context, name string) (api.Status, error) { + f.record("restart_reverb", name) + return f.status, nil +} + func (f *fakeDaemon) StopSite(_ context.Context, name string) (api.Status, error) { f.record("stop", name) return f.status, nil diff --git a/internal/mcpserver/server.go b/internal/mcpserver/server.go index aaa1a0ca..50b35098 100644 --- a/internal/mcpserver/server.go +++ b/internal/mcpserver/server.go @@ -53,6 +53,7 @@ type Daemon interface { WorktreeSetup(ctx context.Context, path string) (api.WorktreeStatus, error) UnpinSite(ctx context.Context, name, tool string) (api.Status, error) RestartSite(ctx context.Context, name string) (api.Status, error) + RestartReverb(ctx context.Context, name string) (api.Status, error) StopSite(ctx context.Context, name string) (api.Status, error) UpdateSettings(ctx context.Context, s api.Settings) (api.Status, error) } @@ -127,6 +128,7 @@ func (s *server) instructions(ctx context.Context) string { } b.WriteString(`- Flock always serves its sites: don't run "php artisan serve" or another web server. Use site_info for the URL. +- Flock runs a Laravel app's Reverb server when update_site's reverb_on turned it on: then don't run "php artisan reverb:start". - Run php, composer, node and npm as usual: Flock's shims pick the versions the project uses. - When code uses a PHP extension (imagick, intl, ...), require it in composer.json ("composer require ext-:*"). php_extensions shows what the project requires and what Flock's PHP has. - Mail the app sends is caught by Flock, not delivered: read it with list_mail and read_mail. diff --git a/internal/mcpserver/tools_read.go b/internal/mcpserver/tools_read.go index 47e3f69f..eb3678cb 100644 --- a/internal/mcpserver/tools_read.go +++ b/internal/mcpserver/tools_read.go @@ -203,7 +203,11 @@ type siteInfo struct { WorktreeOf string `json:"worktree_of,omitempty"` Branch string `json:"branch,omitempty"` Problem string `json:"problem,omitempty"` - Notice string `json:"notice,omitempty"` + // Reverb is the state of the site's Laravel Reverb server when it's on; ReverbEnv the .env lines + // that send the app's broadcasts and its Echo client through Flock (wss://.test/app). + Reverb string `json:"reverb_state,omitempty"` + ReverbEnv []string `json:"reverb_env,omitempty"` + Notice string `json:"notice,omitempty"` } func siteInfoOf(st api.SiteStatus) siteInfo { @@ -238,6 +242,12 @@ func siteInfoOf(st api.SiteStatus) siteInfo { info.Problem = st.FrankenPHP.Problem } } + if st.Reverb != nil { + info.Reverb, info.ReverbEnv = st.Reverb.State, st.Reverb.Env + if info.Problem == "" { + info.Problem = st.Reverb.Problem + } + } return info } diff --git a/internal/mcpserver/tools_write.go b/internal/mcpserver/tools_write.go index 8fc279b9..9dfb90cb 100644 --- a/internal/mcpserver/tools_write.go +++ b/internal/mcpserver/tools_write.go @@ -89,11 +89,13 @@ func (s *server) addWriteTools(srv *mcp.Server) { "- add_domain (domain): an extra domain, a subdomain (\"tenant1\" → tenant1..test) or an alias (\"my-app-2.test\").\n" + "- set_server (server, worker): run PHP through FastCGI (PHP-FPM) or FrankenPHP, optionally in Octane's worker mode.\n" + "- set_type (type): serve it as a php site or as a node app, whose dev server (npm run dev) Flock starts on the first visit.\n" + + "- reverb_on / reverb_off: run or stop the Laravel app's Reverb server, reached at wss://.test/app; site_info shows the .env lines it needs.\n" + "- restart: restart a FrankenPHP site's process, e.g. so Octane workers load changed code, or a Node.js app's dev server.\n" + + "- restart_reverb: restart the site's Reverb server, e.g. so it loads changed code.\n" + "- stop: stop a Node.js app's dev server until the site's next request (it also stops after 30 idle minutes).", Annotations: additive(false), InputSchema: schema[updateSiteIn](map[string][]any{ - "action": {"secure", "unsecure", "isolate", "unisolate", "pin", "unpin", "add_domain", "set_server", "set_type", "restart", "stop"}, + "action": {"secure", "unsecure", "isolate", "unisolate", "pin", "unpin", "add_domain", "set_server", "set_type", "reverb_on", "reverb_off", "restart", "restart_reverb", "stop"}, "type": {"php", "node"}, "tool": {"php", "node"}, "server": {"fastcgi", "frankenphp"}, @@ -823,9 +825,16 @@ func (s *server) updateSite(ctx context.Context, _ *mcp.CallToolRequest, in upda } st, err = s.Daemon.UpdateSite(ctx, site.Name, api.SiteChange{Type: &in.Type}) msg = fmt.Sprintf("%s is served as a %s site.", site.Name, config.TypeName(in.Type)) + case "reverb_on", "reverb_off": + on := in.Action == "reverb_on" + st, err = s.Daemon.UpdateSite(ctx, site.Name, api.SiteChange{Reverb: &on}) + msg = fmt.Sprintf("Reverb is %s for %s.", map[bool]string{true: "on", false: "off"}[on], site.Name) case "restart": st, err = s.Daemon.RestartSite(ctx, site.Name) msg = fmt.Sprintf("Restarted %s.", site.Name) + case "restart_reverb": + st, err = s.Daemon.RestartReverb(ctx, site.Name) + msg = fmt.Sprintf("Restarted %s's Reverb server.", site.Name) case "stop": st, err = s.Daemon.StopSite(ctx, site.Name) msg = fmt.Sprintf("Stopped %s's dev server; the next request starts it again.", site.Name) diff --git a/internal/reverb/reverb.go b/internal/reverb/reverb.go new file mode 100644 index 00000000..990127fd --- /dev/null +++ b/internal/reverb/reverb.go @@ -0,0 +1,64 @@ +// Package reverb runs a site's Laravel Reverb server behind Flock's web server, which sends it the +// site's WebSocket connections and the HTTP API Laravel broadcasts through, so browsers reach it at +// wss://.test/app like the rest of the site. +package reverb + +import ( + "os" + "path/filepath" + "strconv" + "syscall" + + "flock/internal/config" + "flock/internal/php" + "flock/internal/supervisor" +) + +// Site is a site whose Reverb server runs. +type Site struct { + Name string + Path string // the project + Minor string // the PHP version + Port int // loopback port the web server proxies to + Env []string + // Restart is bumped to restart the process, e.g. so it loads changed code. + Restart int +} + +// Installed reports whether a project is a Laravel app with Laravel Reverb installed. +func Installed(path string) bool { + return exists(filepath.Join(path, "artisan")) && exists(filepath.Join(path, "vendor", "laravel", "reverb", "composer.json")) +} + +// Spec is the site's `php artisan reverb:start`. The PHP version must be installed. +func Spec(root string, s Site) supervisor.Spec { + name := config.ReverbPortID(s.Name) + // php.Env rather than CLIEnv: a server has no use for pcov. FLOCK_RESTART and FLOCK_PHP_VERSION + // change the spec when the counter changes or a patch update is installed, so the supervisor + // restarts the process. + env := append(php.Env(root, s.Minor), s.Env...) + env = append(env, "FLOCK_RESTART="+strconv.Itoa(s.Restart), "FLOCK_PHP_VERSION="+php.FullVersion(root, s.Minor)) + return supervisor.Spec{ + Name: name, + Path: php.Binary(root, s.Minor), + // --hostname: given the site's name (REVERB_HOST), Reverb serves TLS itself when Herd or + // Valet has a certificate for it, but the web server speaks plain HTTP to it. + Args: []string{"artisan", "reverb:start", "--host=127.0.0.1", "--port=" + strconv.Itoa(s.Port), "--hostname=127.0.0.1", "--no-interaction"}, + Env: env, + Dir: s.Path, + LogFile: filepath.Join(root, "log", name+".log"), + StopSignal: syscall.SIGTERM, + } +} + +// Env is what the app's .env needs for its broadcasts and its Echo client to go through the web +// server: the site's host, and the scheme and port it is served on. install:broadcasting's +// VITE_REVERB_* variables refer to these. +func Env(host, scheme string, port int) []string { + return []string{"REVERB_HOST=" + host, "REVERB_PORT=" + strconv.Itoa(port), "REVERB_SCHEME=" + scheme} +} + +func exists(path string) bool { + _, err := os.Stat(path) + return err == nil +} diff --git a/internal/supervisor/supervisor.go b/internal/supervisor/supervisor.go index 174126b0..bde7a9b0 100644 --- a/internal/supervisor/supervisor.go +++ b/internal/supervisor/supervisor.go @@ -110,6 +110,18 @@ func (s *Supervisor) Running() []string { return names } +// Paths maps each supervised process's name to the executable it was started with. +func (s *Supervisor) Paths() map[string]string { + s.mu.Lock() + defer s.mu.Unlock() + + paths := make(map[string]string, len(s.procs)) + for n, p := range s.procs { + paths[n] = p.spec.Path + } + return paths +} + // Status reports every supervised process, sorted by name. func (s *Supervisor) Status() []ProcStatus { s.mu.Lock() diff --git a/internal/web/serve_test.go b/internal/web/serve_test.go index 94fdbd85..f5b42d6b 100644 --- a/internal/web/serve_test.go +++ b/internal/web/serve_test.go @@ -577,3 +577,112 @@ func TestAProxyWhoseServerIsDownSaysSo(t *testing.T) { t.Errorf("franken.test: want Caddy's plain 502, got %d %v %q", resp.StatusCode, resp.Header, body) } } + +// TestSendsReverbRequestsToReverb runs the embedded Caddy in front of a stand-in Reverb server: a +// WebSocket connection over HTTPS and a signed broadcast reach it, the app's own pages under +// /app and /apps don't. +func TestSendsReverbRequestsToReverb(t *testing.T) { + dir, err := os.MkdirTemp("", "flock-web-test-") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.RemoveAll(dir) }) + if err := os.WriteFile(filepath.Join(dir, "index.php"), []byte(", as pusher-js opens it. + conn, err := tls.Dial("tcp", fmt.Sprintf("127.0.0.1:%d", cfg.HTTPSPort), tlsConfig) + if err != nil { + t.Fatal(err) + } + defer conn.Close() + _ = conn.SetDeadline(time.Now().Add(5 * time.Second)) + fmt.Fprint(conn, "GET /app/key?protocol=7&client=js HTTP/1.1\r\nHost: chat.test\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n"+ + "Sec-WebSocket-Version: 13\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n\r\n") + head := make([]byte, 512) + n, err := conn.Read(head) + if err != nil || !strings.HasPrefix(string(head[:n]), "HTTP/1.1 101") { + t.Fatalf("WebSocket handshake: %v %q", err, head[:n]) + } + fmt.Fprint(conn, "ping") + if n, err = io.ReadFull(conn, head[:4]); err != nil || string(head[:n]) != "ping" { + t.Fatalf("through the WebSocket: %v %q", err, head[:n]) + } + + client := &http.Client{Timeout: 5 * time.Second, Transport: &http.Transport{ + TLSClientConfig: &tls.Config{RootCAs: pool}, + DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { + _, port, _ := net.SplitHostPort(addr) + return (&net.Dialer{}).DialContext(ctx, network, "127.0.0.1:"+port) + }, + ForceAttemptHTTP2: true, + }} + for path, want := range map[string]string{ + "/apps/1/events?auth_key=k&auth_timestamp=1&auth_signature=s": "reverb /apps/1/events", // a broadcast + "/app/key?protocol=7&client=js": "reverb /app/key", // a WebSocket over HTTP/2 has no Upgrade header + "/app/dashboard": "", // the app's pages: PHP, a 502 here + "/app/servers?protocol=ssh": "", // with a ?protocol= filter of the app's own + "/apps/list": "", + } { + resp, err := client.Post(base+path, "application/json", strings.NewReader("{}")) + if err != nil { + t.Errorf("%s: %v", path, err) + continue + } + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + if want == "" && resp.StatusCode != http.StatusBadGateway || want != "" && string(body) != want { + t.Errorf("%s: got %d %q", path, resp.StatusCode, body) + } + } +} diff --git a/internal/web/web.go b/internal/web/web.go index a33566c0..84d1d99b 100644 --- a/internal/web/web.go +++ b/internal/web/web.go @@ -45,6 +45,9 @@ type Site struct { // Node is set on a Node.js site: its name, which every request passes to Config.Wake first // (starting its dev server, at Proxy, when it isn't running). Node string + // Reverb is the loopback address of the site's Laravel Reverb server, which gets the site's + // WebSocket connections and broadcasts (see reverbRequest). + Reverb string // CertFile and KeyFile (from certs.SiteCert, covering Domain and *.Domain) are set when the site // is secured. Deeper subdomains and aliases get certificates from Config.Issue. CertFile string @@ -274,14 +277,30 @@ func upstreamDown(s Site, cfg Config) string { const xdebugRequest = `{cookie.XDEBUG_SESSION} != "" || {cookie.XDEBUG_TRIGGER} != "" || ` + `{query.XDEBUG_SESSION} != "" || {query.XDEBUG_SESSION_START} != "" || {query.XDEBUG_TRIGGER} != ""` +// reverbRequest matches the Pusher protocol's requests on /app/* and /apps/*: WebSocket connections +// (an Upgrade header; ?protocol=…&client=…, which pusher-js always sends, also covers WebSockets over +// HTTP/2, which have none) and the signed HTTP API Laravel broadcasts through. Other requests there +// stay the app's, e.g. a panel at /app, even with a ?protocol= filter of its own. +const reverbRequest = `{http.request.header.Upgrade} != "" || ({query.protocol} != "" && {query.client} != "") || {query.auth_signature} != ""` + func app(s Site) string { if strings.TrimSpace(s.Handlers) == "" { - return root(s, "\t") + dispatch(s, "\t") + return root(s, "\t") + reverb(s, "\t") + dispatch(s, "\t") } // A route keeps the written order, so the custom handlers run before the Xdebug/default handle // pair. The remote abort still runs first: it is ordered before every directive, route too. - return "\troute {\n" + root(s, "\t\t") + handlers(s.Handlers, "\t\t") + dispatch(s, "\t\t") + "\t}\n" + return "\troute {\n" + root(s, "\t\t") + reverb(s, "\t\t") + handlers(s.Handlers, "\t\t") + dispatch(s, "\t\t") + "\t}\n" +} + +// reverb sends the site's Reverb requests (see reverbRequest) to its Reverb server, ahead of the +// site's other handlers. +func reverb(s Site, indent string) string { + if s.Reverb == "" { + return "" + } + return fmt.Sprintf("%[1]s@reverb {\n%[1]s\tpath /app/* /apps/*\n%[1]s\texpression `%[2]s`\n%[1]s}\n%[1]shandle @reverb {\n%[3]s%[1]s}\n", + indent, reverbRequest, proxy(s.Reverb, indent+"\t")) } func root(s Site, indent string) string { @@ -373,9 +392,9 @@ func CheckHandlers(s Site, name string) error { return nil } -// proxy sends every request to a FrankenPHP process, which serves the static files too, or to a -// proxied name's server. The Host header passes unchanged, so the app sees its own domain, and -// WebSocket upgrades (a dev server's hot reload) pass through. +// proxy sends requests to a site's own process (FrankenPHP, which serves the static files too, or +// Reverb) or to a proxied name's server. The Host header passes unchanged, so the app sees its own +// domain, and WebSocket upgrades (a dev server's hot reload, Reverb's connections) pass through. func proxy(upstream, indent string) string { // Caddy sends an HTTPS upstream its own host:port as the Host header; keep the request's instead. if strings.HasPrefix(upstream, "https://") { diff --git a/internal/web/web_test.go b/internal/web/web_test.go index 179c2d8a..00588fbf 100644 --- a/internal/web/web_test.go +++ b/internal/web/web_test.go @@ -275,3 +275,39 @@ func TestCaddyfileRejectsRemoteClientsBeforeAnyHandler(t *testing.T) { t.Fatalf("no routes in:\n%s", adapted) } } + +func TestCaddyfileSendsReverbRequestsFirst(t *testing.T) { + cfg := testConfig() + cfg.Sites = []Site{ + {Domain: "chat.test", Root: "/code/chat/public", FPM: []string{"unix//run/php84.sock"}, Reverb: "127.0.0.1:21001", DebugFPM: []string{"unix//run/php84-xdebug.sock"}}, + {Domain: "octane.test", Root: "/code/octane/public", Proxy: "127.0.0.1:21002", Reverb: "127.0.0.1:21003"}, + } + + cf := Caddyfile(cfg) + json, _, err := caddyconfig.GetAdapter("caddyfile").Adapt([]byte(cf), nil) + if err != nil { + t.Fatalf("%v\n%s", err, cf) + } + for _, want := range []string{`"dial":"127.0.0.1:21001"`, `"dial":"127.0.0.1:21003"`, `"/apps/*"`, `query.auth_signature`} { + if !strings.Contains(string(json), want) { + t.Errorf("missing %s in:\n%s", want, json) + } + } + // Xdebug's handle block would win if it came first. + if strings.Index(cf, "handle @reverb") > strings.Index(cf, "handle @xdebug") { + t.Errorf("Reverb's route comes after Xdebug's:\n%s", cf) + } + if strings.Contains(Caddyfile(testConfig()), "@reverb") { + t.Error("a site without Reverb has its route") + } + + // With custom handlers the site is a route, which keeps the written order: Reverb comes first. + cfg.Sites[0].Handlers = "handle /app/* {\n\trespond custom 200\n}\n" + cf = Caddyfile(cfg) + if _, _, err := caddyconfig.GetAdapter("caddyfile").Adapt([]byte(cf), nil); err != nil { + t.Fatalf("%v\n%s", err, cf) + } + if r, c := strings.Index(cf, "\t\thandle @reverb"), strings.Index(cf, "\t\thandle /app/*"); r < 0 || c < 0 || r > c { + t.Errorf("Reverb's route isn't ahead of the custom handlers in the route:\n%s", cf) + } +} diff --git a/linux/src/api.rs b/linux/src/api.rs index 346c5742..a9575407 100644 --- a/linux/src/api.rs +++ b/linux/src/api.rs @@ -263,6 +263,11 @@ impl Client { self.request("POST", &format!("/v1/sites/{}/restart", escape(name)), None::<&()>, Duration::from_secs(60)) } + /// Restarts a site's Reverb server, e.g. so it loads changed code. + pub fn restart_reverb(&self, name: &str) -> Result { + self.request("POST", &format!("/v1/sites/{}/reverb/restart", escape(name)), None::<&()>, Duration::from_secs(60)) + } + /// Puts a Node.js site's dev server to sleep until the site's next visit. pub fn stop_site(&self, name: &str) -> Result { self.request("POST", &format!("/v1/sites/{}/stop", escape(name)), None::<&()>, Duration::from_secs(60)) @@ -1168,6 +1173,10 @@ pub struct SiteStatus { pub worker: String, pub octane_available: bool, pub frankenphp: Option, + /// The site's Reverb server, while it's turned on; available when the project has Laravel + /// Reverb installed. + pub reverb: Option, + pub reverb_available: bool, /// php or node; older daemons send none, which means php. #[serde(rename = "type")] pub kind: String, @@ -1227,6 +1236,11 @@ impl SiteStatus { _ => true, } } + + /// Where browsers reach the site's Reverb server: wss://acme.test/app, ws:// without HTTPS. + pub fn reverb_url(&self) -> String { + format!("{}://{}/app", if self.secured { "wss" } else { "ws" }, self.domain) + } } impl Status { @@ -1269,6 +1283,25 @@ pub struct SiteFrankenPhp { pub log: String, } +/// A site's Laravel Reverb server. +#[derive(Deserialize, Debug, Clone, PartialEq, Default)] +#[serde(default)] +pub struct SiteReverb { + /// running, failed or unavailable. + pub state: String, + pub port: u16, + /// The app's .env lines for reaching it through the web server. + pub env: Vec, + pub problem: String, + pub log: String, +} + +impl SiteReverb { + pub fn running(&self) -> bool { + self.state == "running" + } +} + /// A Node.js site's dev server, which Flock starts on the site's first visit. #[derive(Deserialize, Debug, Clone, PartialEq, Default)] #[serde(default)] @@ -1530,6 +1563,8 @@ pub struct SiteChange { #[serde(skip_serializing_if = "Option::is_none")] pub worker: Option, #[serde(skip_serializing_if = "Option::is_none")] + pub reverb: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub root: Option, } @@ -1677,6 +1712,38 @@ mod tests { assert_eq!(serde_json::to_value(change).unwrap(), serde_json::json!({ "type": "node" })); } + #[test] + fn decodes_reverb() { + let json = r#"{"sites":[ + {"name":"acme","domain":"acme.test","path":"/home/a/acme","php":"8.4","secured":true,"reverb_available":true, + "reverb":{"state":"running","port":28080,"env":["REVERB_HOST=acme.test","REVERB_PORT=443","REVERB_SCHEME=https"], + "log":"reverb-acme"}}, + {"name":"blog","domain":"blog.test","path":"/home/a/blog","php":"8.4", + "reverb":{"state":"unavailable","port":0,"problem":"Laravel Reverb isn't installed in the project","log":"reverb-blog"}}, + {"name":"shop","domain":"shop.test","path":"/home/a/shop","php":"8.4"}]}"#; + let status: Status = serde_json::from_str(json).unwrap(); + let acme = &status.sites[0]; + assert!(acme.reverb_available); + let reverb = acme.reverb.as_ref().unwrap(); + assert!(reverb.running()); + assert_eq!(reverb.port, 28080); + assert_eq!(reverb.env, ["REVERB_HOST=acme.test", "REVERB_PORT=443", "REVERB_SCHEME=https"]); + assert_eq!(reverb.log, "reverb-acme"); + assert_eq!(acme.reverb_url(), "wss://acme.test/app"); + let blog = &status.sites[1]; + assert!(!blog.reverb_available); + let reverb = blog.reverb.as_ref().unwrap(); + assert!(!reverb.running() && reverb.env.is_empty() && reverb.problem.contains("isn't installed")); + assert_eq!(blog.reverb_url(), "ws://blog.test/app"); + // Turned off, or an older daemon's site. + assert!(status.sites[2].reverb.is_none() && !status.sites[2].reverb_available); + + let on = SiteChange { reverb: Some(true), ..Default::default() }; + assert_eq!(serde_json::to_value(on).unwrap(), serde_json::json!({ "reverb": true })); + let off = SiteChange { reverb: Some(false), ..Default::default() }; + assert_eq!(serde_json::to_value(off).unwrap(), serde_json::json!({ "reverb": false })); + } + #[test] fn decodes_proxy_commands() { let json = r#"{"proxies":[ diff --git a/linux/src/panes/sites.rs b/linux/src/panes/sites.rs index 354a6839..9cd60f6f 100644 --- a/linux/src/panes/sites.rs +++ b/linux/src/panes/sites.rs @@ -1,7 +1,8 @@ //! Sites: the sites added one by one and those of the project folders, and a site's detail: its -//! address, domains and document root, its PHP and Node.js versions, what serves it, its share and -//! Laravel Boost. Version and root pickers offer the project's pin (or the default) first, then -//! what isolates the site on this machine; the pin menu writes them into the project. +//! address, domains and document root, its PHP and Node.js versions, what serves it, its Reverb +//! server, its share and Laravel Boost. Version and root pickers offer the project's pin (or the +//! default) first, then what isolates the site on this machine; the pin menu writes them into the +//! project. //! //! A Node.js site has no PHP settings: its detail shows its dev server instead, which Flock starts //! on the site's first visit, with the server's output. @@ -22,13 +23,13 @@ use adw::prelude::*; use base64::Engine; use gtk::{gdk, gio, glib}; -use super::common::{PaneView, ask_link, group, info_row, report, state_dot, status_dot}; +use super::common::{PaneView, ask_link, code_block, group, info_row, report, state_dot, status_dot, switch_row}; use super::versions::{MenuItem, badge, button, more_menu}; use super::worktree_job; use super::{Data, Pane}; use crate::api::{ Branches, Client, Config, Error, ExtensionReport, PhpExtension, ProxyChange, ProxyCommand, ProxyCommandChange, ProxyStatus, - ShareStatus, SiteChange, SiteNodeServer, SiteStatus, Status, WorktreeStatus, + ShareStatus, SiteChange, SiteNodeServer, SiteReverb, SiteStatus, Status, WorktreeStatus, }; use crate::links::{self, Link}; use crate::model::AppModel; @@ -49,12 +50,15 @@ struct Inner { selected: RefCell>, /// The FrankenPHP site that is restarting. restarting: RefCell>, + /// The site whose Reverb server is restarting. + restarting_reverb: RefCell>, shown: RefCell>, /// The sites whose worktrees the list shows (prefs.rs). expanded: RefCell>, /// The folder of the worktree being removed. removing: RefCell>, - /// The output of a dev server or a proxy's command, moved into each detail that shows one. + /// The output of a dev server, a Reverb server or a proxy's command, moved into each detail that + /// shows one. output: Rc, } @@ -89,6 +93,7 @@ impl SitesPane { config: RefCell::default(), selected: RefCell::default(), restarting: RefCell::default(), + restarting_reverb: RefCell::default(), shown: RefCell::default(), expanded: RefCell::new(prefs::load().expanded_sites), removing: RefCell::default(), @@ -136,11 +141,12 @@ impl Inner { fn render(self: &Rc) { self.ask_link(); let key = format!( - "{:?}{:?}{:?}{:?}{:?}", + "{:?}{:?}{:?}{:?}{:?}{:?}", self.status.borrow(), self.config.borrow(), self.selected.borrow(), self.restarting.borrow(), + self.restarting_reverb.borrow(), self.removing.borrow() ); if self.shown.borrow().as_deref() == Some(key.as_str()) { @@ -452,6 +458,10 @@ impl Inner { groups.push(self.output.show(&server.log, server.live())); } else { groups.push(self.server_group(site).upcast()); + groups.push(self.reverb_group(site).upcast()); + if let Some(reverb) = &site.reverb { + groups.push(self.output.show(&reverb_log(site, reverb), reverb.running())); + } if site.boost == "missing" || site.boost == "outdated" { groups.push(self.boost_group(site).upcast()); } @@ -1029,6 +1039,78 @@ impl Inner { ); } + /// Laravel Reverb for a PHP site: Run Reverb, then its state, the .env lines the app needs and + /// Restart. Its output follows the group. + fn reverb_group(self: &Rc, site: &SiteStatus) -> adw::PreferencesGroup { + let address = site.reverb_url(); + let footer = if site.reverb.is_some() { + "WebSocket connections under /app and Laravel's broadcasts under /apps go to Reverb; every other request stays the app's. Flock doesn't change the project's .env. Rebuild the front end after changing it.".to_owned() + } else if site.reverb_available { + format!( + "Flock runs php artisan reverb:start for the site. Browsers reach it at {address}, with no port or certificate of its own." + ) + } else { + "Install Laravel Reverb in the project (php artisan install:broadcasting --reverb) to run it.".to_owned() + }; + let group = group("Reverb", &footer); + let (this, name) = (self.clone(), site.name.clone()); + let run = switch_row("Run Reverb", "", site.reverb.is_some(), move |on| { + this.change(&name, SiteChange { reverb: Some(on), ..Default::default() }); + }); + run.set_sensitive(site.reverb_available || site.reverb.is_some()); + group.add(&run); + let Some(reverb) = &site.reverb else { return group }; + + let (text, dot, class) = match reverb.state.as_str() { + "running" => (format!("Running at {address}"), "success", ""), + "unavailable" => (reverb.problem.clone(), "warning", "warning"), + _ if reverb.problem.is_empty() => ("Failed".to_owned(), "error", ""), + _ => (reverb.problem.clone(), "error", ""), + }; + let state = adw::ActionRow::builder().title(glib::markup_escape_text(&text)).build(); + if !class.is_empty() { + state.add_css_class(class); + } + state.add_prefix(>k::Label::builder().label("●").css_classes([dot]).build()); + let restarting = self.restarting_reverb.borrow().as_deref() == Some(site.name.as_str()); + if restarting { + state.add_suffix(>k::Spinner::builder().spinning(true).build()); + } + let (this, name) = (self.clone(), site.name.clone()); + let restart = button("Restart", false, move || this.restart_reverb(&name)); + restart.set_sensitive(reverb.running() && !restarting); + restart.set_tooltip_text(Some("Restart Reverb, e.g. so it loads changed code")); + state.add_suffix(&restart); + group.add(&state); + + if !reverb.env.is_empty() { + let env = reverb.env.join("\n"); + let lines = code_block(&env); + let row = adw::ActionRow::builder().title("The app's .env needs:").build(); + row.add_suffix(&button("Copy", false, { + let row = row.clone(); + move || row.clipboard().set_text(&env) + })); + group.add(&row); + group.add(&lines); + } + group + } + + fn restart_reverb(self: &Rc, name: &str) { + self.restarting_reverb.replace(Some(name.to_owned())); + self.render(); + let (this, banner, n) = (self.clone(), self.banner.clone(), name.to_owned()); + self.model.perform( + move |c| c.restart_reverb(&n), + move |e| { + this.restarting_reverb.replace(None); + report(&banner, e); + this.render(); + }, + ); + } + /// A Node.js site's dev server: its state with Start, Stop and Restart, and the command it runs. fn dev_server_group(self: &Rc, site: &SiteStatus, server: &SiteNodeServer) -> adw::PreferencesGroup { let group = @@ -2219,8 +2301,8 @@ fn slug(branch: &str) -> String { out.trim_matches('-').to_owned() } -/// What a site's list row warns about: the site, its document root, a domain, or its FrankenPHP. A -/// dev server that failed shows in its dot. +/// What a site's list row warns about: the site, its document root, a domain, its FrankenPHP or its +/// Reverb server. A dev server that failed shows in its dot. fn problem(site: &SiteStatus) -> Option { if let Some(p) = site.problem.clone().or_else(|| site.notice.clone()) { return Some(p); @@ -2232,6 +2314,17 @@ fn problem(site: &SiteStatus) -> Option { .as_ref() .filter(|f| site.server == "frankenphp" && (f.state == "failed" || f.state == "unavailable") && !f.problem.is_empty()) .map(|f| f.problem.clone()) + .or_else(|| { + site.reverb + .as_ref() + .filter(|r| !site.is_node() && (r.state == "failed" || r.state == "unavailable") && !r.problem.is_empty()) + .map(|r| r.problem.clone()) + }) +} + +/// The service log with a Reverb server's output. +fn reverb_log(site: &SiteStatus, reverb: &SiteReverb) -> String { + if reverb.log.is_empty() { format!("reverb-{}", site.name) } else { reverb.log.clone() } } /// A proxy's suffixes in the list: why it isn't served, whether something answers at its diff --git a/macos/Sources/FlockKit/Client.swift b/macos/Sources/FlockKit/Client.swift index 65ab4940..a69b4ac6 100644 --- a/macos/Sources/FlockKit/Client.swift +++ b/macos/Sources/FlockKit/Client.swift @@ -121,6 +121,11 @@ public actor FlockClient { try await decode(Status.self, "POST", "/v1/sites/\(escape(name))/restart", timeout: Self.siteRestartTimeout) } + /// Restarts only a site's Reverb server, e.g. so it loads changed code. + public func restartReverb(_ name: String) async throws -> Status { + try await decode(Status.self, "POST", "/v1/sites/\(escape(name))/reverb/restart", timeout: Self.siteRestartTimeout) + } + /// Puts a Node.js site's dev server to sleep until the site's next request. public func stopSite(_ name: String) async throws -> Status { try await decode(Status.self, "POST", "/v1/sites/\(escape(name))/stop", timeout: Self.siteRestartTimeout) diff --git a/macos/Sources/FlockKit/Models.swift b/macos/Sources/FlockKit/Models.swift index d57ce3b3..324d6f8d 100644 --- a/macos/Sources/FlockKit/Models.swift +++ b/macos/Sources/FlockKit/Models.swift @@ -220,6 +220,10 @@ public struct Status: Decodable, Sendable, Equatable { public var octaneAvailable: Bool /// The site's FrankenPHP process, when `server` is FrankenPHP. Until it runs, PHP-FPM serves the site. public var frankenPHP: SiteFrankenPHP? + /// The project has Laravel Reverb installed, so Flock can run its WebSocket server. + public var reverbAvailable: Bool + /// The site's Reverb server, while Reverb is turned on for it. + public var reverb: SiteReverb? /// Whether the project uses Laravel Boost and has Flock's guideline. public var boost: Boost /// The site's tunnel (`flock share`), while there is one. @@ -275,7 +279,7 @@ public struct Status: Decodable, Sendable, Equatable { enum CodingKeys: String, CodingKey { case name, domain, path, php, phpSource, phpPinned, phpFile, node, nodeSource, nodePinned, nodeFile, nodeShadowed case secured, parked, url, problem, notice, root, rootSource, rootPinned, rootFile, rootState, rootSuggestions - case domains, debugging, server, worker, octaneAvailable, boost, share, parent, branch, worktree + case domains, debugging, server, worker, octaneAvailable, reverbAvailable, reverb, boost, share, parent, branch, worktree case type, nodeServer case frankenPHP = "frankenphp" } @@ -311,6 +315,8 @@ public struct Status: Decodable, Sendable, Equatable { worker = try c.decodeIfPresent(Worker.self, forKey: .worker) ?? .classic octaneAvailable = try c.decodeIfPresent(Bool.self, forKey: .octaneAvailable) ?? false frankenPHP = try c.decodeIfPresent(SiteFrankenPHP.self, forKey: .frankenPHP) + reverbAvailable = try c.decodeIfPresent(Bool.self, forKey: .reverbAvailable) ?? false + reverb = try c.decodeIfPresent(SiteReverb.self, forKey: .reverb) boost = try c.decodeIfPresent(Boost.self, forKey: .boost) ?? .none share = try c.decodeIfPresent(ShareStatus.self, forKey: .share) parent = try c.decodeIfPresent(String.self, forKey: .parent).flatMap { $0.isEmpty ? nil : $0 } @@ -421,6 +427,42 @@ public struct Status: Decodable, Sendable, Equatable { } } + /// A site's Laravel Reverb server (php artisan reverb:start), reached at /app on the site's domain. + public struct SiteReverb: Decodable, Sendable, Equatable { + public var state: State + public var port: Int + /// The lines the app's .env needs, e.g. "REVERB_HOST=app.test". + public var env: [String] + /// Why it doesn't run (failed, unavailable). + public var problem: String + /// The name of its service log. + public var log: String + + public enum State: String, Decodable, Sendable { + case running + /// See `problem`. + case failed + /// It can't run for the site; see `problem`. + case unavailable + case unknown + + public init(from decoder: Decoder) throws { + self = State(rawValue: try decoder.singleValueContainer().decode(String.self)) ?? .unknown + } + } + + enum CodingKeys: String, CodingKey { case state, port, env, problem, log } + + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + state = try c.decodeIfPresent(State.self, forKey: .state) ?? .unknown + port = try c.decodeIfPresent(Int.self, forKey: .port) ?? 0 + env = try c.decodeIfPresent([String].self, forKey: .env) ?? [] + problem = try c.decodeIfPresent(String.self, forKey: .problem) ?? "" + log = try c.decodeIfPresent(String.self, forKey: .log) ?? "" + } + } + /// Where a site's PHP or Node.js version comes from, in this order: set for the site in Flock /// on this machine (`flock isolate`), pinned in the project (`flock pin`), Flock's default. public enum VersionSource: String, Decodable, Sendable { diff --git a/macos/Sources/FlockKit/Settings.swift b/macos/Sources/FlockKit/Settings.swift index 579365c4..74cf5eea 100644 --- a/macos/Sources/FlockKit/Settings.swift +++ b/macos/Sources/FlockKit/Settings.swift @@ -194,8 +194,11 @@ public struct SiteChange: Encodable, Sendable { public var worker: String? /// The document root on this machine, relative to the project ("" lets the project or detection decide). public var root: String? + /// Runs Laravel Reverb for the site, or stops it. + public var reverb: Bool? - public init(type: String? = nil, secured: Bool? = nil, php: String? = nil, node: String? = nil, server: String? = nil, worker: String? = nil, root: String? = nil) { + public init(type: String? = nil, secured: Bool? = nil, php: String? = nil, node: String? = nil, server: String? = nil, worker: String? = nil, root: String? = nil, + reverb: Bool? = nil) { self.type = type self.secured = secured self.php = php @@ -203,6 +206,7 @@ public struct SiteChange: Encodable, Sendable { self.server = server self.worker = worker self.root = root + self.reverb = reverb } } diff --git a/macos/Sources/FlockUI/Settings/SitesPane.swift b/macos/Sources/FlockUI/Settings/SitesPane.swift index 7fb59aab..0b58814d 100644 --- a/macos/Sources/FlockUI/Settings/SitesPane.swift +++ b/macos/Sources/FlockUI/Settings/SitesPane.swift @@ -35,6 +35,12 @@ struct SitesPane: View { get { restartingState.wrappedValue } nonmutating set { restartingState.wrappedValue = newValue } } + /// The site whose Reverb server is restarting. + private let restartingReverbState = State(initialValue: nil) + private var restartingReverb: String? { + get { restartingReverbState.wrappedValue } + nonmutating set { restartingReverbState.wrappedValue = newValue } + } /// The site the Share sheet is open for. private let sharingState = State(initialValue: nil) private var sharing: String? { @@ -485,7 +491,8 @@ struct SitesPane: View { .help(worktree.path) } - /// What a site's list row warns about: the site, its document root, one of its domains, or its FrankenPHP. + /// What a site's list row warns about: the site, its document root, one of its domains, its FrankenPHP + /// or its Reverb server. private func problem(_ site: Status.Site) -> String? { if let problem = site.problem { return problem @@ -500,6 +507,9 @@ struct SitesPane: View { !franken.problem.isEmpty { return franken.problem } + if !site.isNode, let reverb = site.reverb, reverb.state == .failed || reverb.state == .unavailable, !reverb.problem.isEmpty { + return reverb.problem + } return nil } @@ -649,6 +659,9 @@ struct SitesPane: View { section("Server") { serverFields(site) } + section("Reverb") { + reverbFields(site) + } } if site.boost == .missing || site.boost == .outdated { @@ -936,6 +949,102 @@ struct SitesPane: View { return "frankenphp-\(site.name)" } + /// Laravel Reverb for the site: Run Reverb, then its state, the .env lines the app needs, Restart + /// and its output. + @ViewBuilder + private func reverbFields(_ site: Status.Site) -> some View { + let store = model.settings + let address = "\(site.secured ? "wss" : "ws")://\(site.domain)/app" + field("Run Reverb") { + Toggle("Run Reverb", isOn: Binding( + get: { site.reverb != nil }, + set: { on in Task { await store.perform { _ = try await $0.updateSite(site.name, SiteChange(reverb: on)) } } } + )) + .labelsHidden() + .toggleStyle(.switch) + .controlSize(.small) + .disabled(!site.reverbAvailable && site.reverb == nil) + } + + if let reverb = site.reverb { + reverbState(reverb, address: address) + + if !reverb.env.isEmpty { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 8) { + Text("The app's .env needs:") + .font(.caption).foregroundStyle(.secondary) + Button("Copy") { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(reverb.env.joined(separator: "\n"), forType: .string) + } + .controlSize(.small) + } + Text(reverb.env.joined(separator: "\n")) + .font(.callout.monospaced()) + .textSelection(.enabled) + .fixedSize(horizontal: false, vertical: true) + } + } + + HStack(spacing: 8) { + Button("Restart") { + restartingReverb = site.name + Task { + await store.perform { _ = try await $0.restartReverb(site.name) } + restartingReverb = nil + } + } + .disabled(reverb.state != .running || restartingReverb == site.name) + .help("Restart Reverb, e.g. so it loads changed code") + if restartingReverb == site.name { + ProgressView().controlSize(.mini) + } + } + .controlSize(.small) + + ServerOutput(log: reverb.log.isEmpty ? "reverb-\(site.name)" : reverb.log, live: reverb.state == .running) + + Text("WebSocket connections under /app and Laravel's broadcasts under /apps go to Reverb; every other request stays the app's. Flock doesn't change the project's .env. Rebuild the front end after changing it.") + .font(.caption).foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } else { + Text(site.reverbAvailable + ? "Flock runs php artisan reverb:start for the site. Browsers reach it at \(address), with no port or certificate of its own." + : "Install Laravel Reverb in the project (php artisan install:broadcasting --reverb) to run it.") + .font(.caption).foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + + /// The Reverb server: running at its address, why it stopped, or why it can't run. + @ViewBuilder + private func reverbState(_ reverb: Status.SiteReverb, address: String) -> some View { + switch reverb.state { + case .running: + HStack(spacing: 6) { + StateDot(color: StateDot.running) + Text("Running at \(address)").textSelection(.enabled) + } + case .failed: + HStack(spacing: 6) { + StateDot(color: StateDot.failed) + Text(reverb.problem.isEmpty ? "Failed" : reverb.problem) + .textSelection(.enabled) + .fixedSize(horizontal: false, vertical: true) + } + case .unavailable: + if !reverb.problem.isEmpty { + Label(reverb.problem, systemImage: "exclamationmark.triangle.fill") + .font(.caption).foregroundStyle(.orange) + .textSelection(.enabled) + .fixedSize(horizontal: false, vertical: true) + } + case .unknown: + EmptyView() + } + } + /// Notes when versions disagree: an isolation over the project's pin, .flockrc over .nvmrc. @ViewBuilder private func hints(_ site: Status.Site) -> some View { diff --git a/macos/Tests/FlockKitTests/ClientTests.swift b/macos/Tests/FlockKitTests/ClientTests.swift index 68265a21..320b324a 100644 --- a/macos/Tests/FlockKitTests/ClientTests.swift +++ b/macos/Tests/FlockKitTests/ClientTests.swift @@ -210,6 +210,23 @@ struct ClientTests { #expect(requests[0].body == #"{"type":"node"}"#) } + @Test func talksToTheReverbEndpoints() async throws { + StubProtocol.reset { _ in (200, #"{"version":"dev","pid":1,"started_at":"2026-10-13T14:10:01Z"}"#) } + let client = client(try apiJSON(token: "t")) + + _ = try await client.updateSite("chat", SiteChange(reverb: true)) + _ = try await client.updateSite("chat", SiteChange(reverb: false)) + _ = try await client.restartReverb("chat") + + let requests = StubProtocol.requests + #expect(requests.map(\.method) == ["PATCH", "PATCH", "POST"]) + #expect(requests.map { URL(string: $0.url)?.path ?? "" } == ["/v1/sites/chat", "/v1/sites/chat", "/v1/sites/chat/reverb/restart"]) + #expect(requests[0].body == #"{"reverb":true}"#) + #expect(requests[1].body == #"{"reverb":false}"#) + // Restarting waits for the server to come back. + #expect(requests[2].timeout == 60) + } + @Test func givesSlowEndpointsLongerThanTheDefault() async throws { StubProtocol.reset { request in request.url.contains("/php/") ? (200, #"{"versions":[]}"#) : request.url.contains("/node/") ? (200, #"{"versions":[]}"#) diff --git a/macos/Tests/FlockKitTests/DecodingTests.swift b/macos/Tests/FlockKitTests/DecodingTests.swift index d6a0f168..084aaba4 100644 --- a/macos/Tests/FlockKitTests/DecodingTests.swift +++ b/macos/Tests/FlockKitTests/DecodingTests.swift @@ -231,6 +231,34 @@ private func decode(_ type: T.Type, _ json: String) throws -> T { #expect(!legacy.supportsFrankenPHP && shop.supportsFrankenPHP) } +@Test func decodesASitesReverb() throws { + let status = try decode(Status.self, """ + {"version":"dev","pid":1,"started_at":"2026-10-13T14:10:01Z", + "sites":[{"name":"chat","domain":"chat.test","path":"/code/chat","php":"8.4","secured":true,"reverb_available":true, + "reverb":{"state":"running","port":23000,"env":["REVERB_HOST=chat.test","REVERB_PORT=443","REVERB_SCHEME=https"],"log":"reverb-chat"}}, + {"name":"feed","domain":"feed.test","path":"/code/feed","php":"8.4","reverb_available":true, + "reverb":{"state":"failed","port":23001,"problem":"reverb:start exited: missing REVERB_APP_KEY","log":"reverb-feed"}}, + {"name":"shop","domain":"shop.test","path":"/code/shop","php":"8.4","reverb_available":true}, + {"name":"legacy","domain":"legacy.test","path":"/code/legacy","php":"8.1"}]} + """) + + let chat = status.sites[0] + #expect(chat.reverbAvailable) + let reverb = try #require(chat.reverb) + #expect(reverb.state == .running && reverb.port == 23000 && reverb.log == "reverb-chat" && reverb.problem.isEmpty) + #expect(reverb.env == ["REVERB_HOST=chat.test", "REVERB_PORT=443", "REVERB_SCHEME=https"]) + + let feed = try #require(status.sites[1].reverb) + #expect(feed.state == .failed && feed.problem.contains("REVERB_APP_KEY") && feed.env.isEmpty) + + // Installed but turned off, and a daemon from before Reverb. + #expect(status.sites[2].reverbAvailable && status.sites[2].reverb == nil) + #expect(!status.sites[3].reverbAvailable && status.sites[3].reverb == nil) + + let odd = try decode(Status.SiteReverb.self, #"{"state":"something-new"}"#) + #expect(odd.state == .unknown && odd.port == 0 && odd.log.isEmpty) +} + @Test func decodesANodeSiteAndItsDevServer() throws { let status = try decode(Status.self, """ {"version":"dev","pid":1,"started_at":"2026-10-13T14:10:01Z", diff --git a/windows/Flock.Kit.Tests/ClientTests.cs b/windows/Flock.Kit.Tests/ClientTests.cs index c4432bc6..326db7e1 100644 --- a/windows/Flock.Kit.Tests/ClientTests.cs +++ b/windows/Flock.Kit.Tests/ClientTests.cs @@ -354,13 +354,17 @@ public async Task SwitchesAndRestartsASitesServer() await client.UpdateSiteAsync("shop", new SiteChange(Worker: "")); await client.UpdateSiteAsync("shop", new SiteChange(Secured: true)); await client.RestartSiteAsync("shop"); + await client.UpdateSiteAsync("shop", new SiteChange(Reverb: true)); + await client.RestartReverbAsync("shop"); Assert.Equal( - ["PATCH /v1/sites/shop", "PATCH /v1/sites/shop", "PATCH /v1/sites/shop", "POST /v1/sites/shop/restart"], + ["PATCH /v1/sites/shop", "PATCH /v1/sites/shop", "PATCH /v1/sites/shop", "POST /v1/sites/shop/restart", "PATCH /v1/sites/shop", "POST /v1/sites/shop/reverb/restart"], handler.Requests.Select(r => $"{r.Method} {r.RequestUri!.PathAndQuery}")); - Assert.Equal(["""{"server":"frankenphp"}""", """{"worker":""}""", """{"secured":true}""", ""], handler.Bodies); - // Switching servers and restarting wait for FrankenPHP to come up; other changes 30 seconds. - Assert.Equal([TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(1), TimeSpan.FromSeconds(30), TimeSpan.FromMinutes(1)], handler.Timeouts); + Assert.Equal(["""{"server":"frankenphp"}""", """{"worker":""}""", """{"secured":true}""", "", """{"reverb":true}""", ""], handler.Bodies); + // Switching servers, turning Reverb on and restarting wait for the process to come up; other changes 30 seconds. + Assert.Equal( + [TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(1), TimeSpan.FromSeconds(30), TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(1)], + handler.Timeouts); } [Fact] diff --git a/windows/Flock.Kit.Tests/DecodingTests.cs b/windows/Flock.Kit.Tests/DecodingTests.cs index 33595980..6ee629af 100644 --- a/windows/Flock.Kit.Tests/DecodingTests.cs +++ b/windows/Flock.Kit.Tests/DecodingTests.cs @@ -323,6 +323,36 @@ public void DecodesWhatRunsASite() Assert.Null(status.Sites[3].FrankenPhp); } + [Fact] + public void DecodesASitesReverb() + { + var status = FlockJson.Decode(""" + {"version":"dev","pid":1,"started_at":"2026-11-11T14:10:01Z","dns":{"tld":"test","addr":""}, + "http_port":80,"https_port":443,"mail":"","xdebug":false,"dumps":false,"mail_count":0,"dump_count":0,"processes":[], + "sites":[{"name":"chat","domain":"chat.test","path":"C:\\code\\chat","php":"8.4","secured":true,"parked":false,"reverb_available":true, + "reverb":{"state":"running","port":22080,"env":["REVERB_HOST=chat.test","REVERB_PORT=443","REVERB_SCHEME=https"],"log":"reverb-chat"}}, + {"name":"gone","domain":"gone.test","path":"C:\\code\\gone","php":"8.4","secured":false,"parked":false,"reverb_available":false, + "reverb":{"state":"unavailable","port":0,"problem":"The project doesn't have Laravel Reverb","log":"reverb-gone"}}, + {"name":"blog","domain":"blog.test","path":"C:\\code\\blog","php":"8.4","secured":false,"parked":false,"reverb_available":true}, + {"name":"old","domain":"old.test","path":"C:\\code\\old","php":"8.3","secured":false,"parked":true}]} + """); + + var chat = status.Sites[0]; + Assert.True(chat.ReverbAvailable); + Assert.True(chat.Reverb!.IsRunning); + Assert.Equal((22080, "reverb-chat", ""), (chat.Reverb.Port, chat.Reverb.Log, chat.Reverb.Problem)); + Assert.Equal(["REVERB_HOST=chat.test", "REVERB_PORT=443", "REVERB_SCHEME=https"], chat.Reverb.Env); + var gone = status.Sites[1].Reverb!; + Assert.Equal((Status.ReverbState.Unavailable, false), (gone.State, gone.IsRunning)); + Assert.NotEmpty(gone.Problem); + Assert.Empty(gone.Env); + Assert.True(status.Sites[2].ReverbAvailable); + Assert.Null(status.Sites[2].Reverb); + // An older daemon sends neither. + Assert.False(status.Sites[3].ReverbAvailable); + Assert.Null(status.Sites[3].Reverb); + } + [Fact] public void DecodesASitesShare() { diff --git a/windows/Flock.Kit/FlockClient.cs b/windows/Flock.Kit/FlockClient.cs index 1ec1118c..937aa99b 100644 --- a/windows/Flock.Kit/FlockClient.cs +++ b/windows/Flock.Kit/FlockClient.cs @@ -78,14 +78,17 @@ public Task> SetServicePortAsync(string id, int port, Cancellation public Task LinkSiteAsync(string path, string? name = null, string? php = null, bool secured = false, CancellationToken cancellationToken = default) => Send(HttpMethod.Post, "/v1/sites", new { path, name, php, secured }, cancellationToken: cancellationToken); - /// Switching the server or worker mode reloads the web server and may start FrankenPHP, so it gets longer. + /// Switching the server or worker mode, or turning Reverb on or off, reloads the web server and may start a process, so it gets longer. public Task UpdateSiteAsync(string name, SiteChange change, CancellationToken cancellationToken = default) => Send(HttpMethod.Patch, $"/v1/sites/{Escape(name)}", change, - timeout: change.Server is not null || change.Worker is not null ? ServerSwitchTimeout : null, cancellationToken: cancellationToken); + timeout: change.Server is not null || change.Worker is not null || change.Reverb is not null ? ServerSwitchTimeout : null, cancellationToken: cancellationToken); /// Restarts a FrankenPHP site's process, e.g. so Octane's workers load changed code, or a Node.js site's dev server (starting it when it sleeps). public Task RestartSiteAsync(string name, CancellationToken cancellationToken = default) => Send(HttpMethod.Post, $"/v1/sites/{Escape(name)}/restart", timeout: ServerSwitchTimeout, cancellationToken: cancellationToken); + /// Restarts only a site's Reverb server, e.g. so it loads changed code. + public Task RestartReverbAsync(string name, CancellationToken cancellationToken = default) => Send(HttpMethod.Post, $"/v1/sites/{Escape(name)}/reverb/restart", timeout: ServerSwitchTimeout, cancellationToken: cancellationToken); + /// Puts a Node.js site's dev server to sleep until the site's next request. public Task StopSiteAsync(string name, CancellationToken cancellationToken = default) => Send(HttpMethod.Post, $"/v1/sites/{Escape(name)}/stop", timeout: ServerSwitchTimeout, cancellationToken: cancellationToken); diff --git a/windows/Flock.Kit/Models.cs b/windows/Flock.Kit/Models.cs index c5ceb3fb..b644e4de 100644 --- a/windows/Flock.Kit/Models.cs +++ b/windows/Flock.Kit/Models.cs @@ -322,6 +322,10 @@ public sealed class Site /// The site's FrankenPHP process, when Server is FrankenPHP. [JsonPropertyName("frankenphp")] public SiteFrankenPhp? FrankenPhp { get; init; } + /// The project has Laravel Reverb installed, so Flock can run it. + public bool ReverbAvailable { get; init; } + /// The site's Reverb server, while it is turned on. + public SiteReverb? Reverb { get; init; } /// What the site is: SiteType.Php (also when an older daemon leaves it out) or SiteType.Node. public string Type { get; init; } = SiteType.Php; /// A Node.js site's dev server. @@ -447,6 +451,34 @@ public sealed class SiteFrankenPhp public bool HasProgress => Progress is >= 0 and <= 1; } + /// + /// A site's Laravel Reverb server (php artisan reverb:start), which browsers reach at + /// wss://<domain>/app. Problem says why it isn't running. + /// + public sealed class SiteReverb + { + /// One of ReverbState; "" when unknown. + public string State { get; init; } = ""; + public int Port { get; init; } + /// What the app's .env needs, e.g. "REVERB_HOST=app.test". + public List Env { get; init; } = []; + public string Problem { get; init; } = ""; + /// Name for /v1/logs/services/{name}: its output. + public string Log { get; init; } = ""; + + [JsonIgnore] + public bool IsRunning => State == ReverbState.Running; + } + + /// Values of SiteReverb.State. + public static class ReverbState + { + public const string Running = "running"; + public const string Failed = "failed"; + /// It can't run, e.g. the project no longer has Reverb. + public const string Unavailable = "unavailable"; + } + /// /// A site shared at a temporary public URL through a Cloudflare quick tunnel. It is followed /// through the status: building the assets, starting the tunnel, running, or failed (see Error). diff --git a/windows/Flock.Kit/Settings.cs b/windows/Flock.Kit/Settings.cs index 10619671..19701a83 100644 --- a/windows/Flock.Kit/Settings.cs +++ b/windows/Flock.Kit/Settings.cs @@ -105,9 +105,10 @@ public sealed record SettingsChange( /// /// Body of PATCH /v1/sites/{name}; null fields are left out. An empty Php means "use the default"; -/// Server is "fastcgi" or "frankenphp", Worker "" (classic) or "octane" (see Status.SiteServer). +/// Server is "fastcgi" or "frankenphp", Worker "" (classic) or "octane" (see Status.SiteServer); +/// Reverb turns the site's Reverb server on or off. /// -public sealed record SiteChange(bool? Secured = null, string? Php = null, string? Node = null, string? Server = null, string? Worker = null, string? Root = null, string? Type = null); +public sealed record SiteChange(bool? Secured = null, string? Php = null, string? Node = null, string? Server = null, string? Worker = null, string? Root = null, string? Type = null, bool? Reverb = null); /// /// A change to a proxy (PATCH /v1/proxies/{name}); null fields stay as they are. An empty Command diff --git a/windows/Flock.Tray.Tests/SiteDetailTests.cs b/windows/Flock.Tray.Tests/SiteDetailTests.cs new file mode 100644 index 00000000..487a16c3 --- /dev/null +++ b/windows/Flock.Tray.Tests/SiteDetailTests.cs @@ -0,0 +1,502 @@ +using System.Net; +using Flock.Kit; +using Flock.Tray.Features.Sites; +using Flock.Tray.Infrastructure; +using Flock.Tray.ViewModels; + +namespace Flock.Tray.Tests; + +/// The view models of a site's, worktree's and proxy's details in Settings → Sites. +public sealed class SiteDetailTests : IDisposable +{ + private readonly string directory = Directory.CreateTempSubdirectory("flock-detail-").FullName; + private readonly Handler handler = new(); + private readonly HttpClient http; + private readonly FlockClient client; + private readonly Host host; + private readonly OutputViewModel output; + + public SiteDetailTests() + { + var config = Path.Combine(directory, "api.json"); + File.WriteAllText(config, "{\"url\":\"http://127.0.0.1:1\",\"token\":\"test\"}"); + http = new HttpClient(handler) { Timeout = Timeout.InfiniteTimeSpan }; + client = new FlockClient(http, config); + host = new Host(new SitesService(client, new OperationRunner())); + output = new OutputViewModel(host.Sites, _ => { }); + } + + public void Dispose() + { + output.Stop(); + client.Dispose(); + http.Dispose(); + Directory.Delete(directory, recursive: true); + } + + [Fact] + public void TheMenuOffersOnlyWhatCanBePinnedOrUnpinned() + { + var detail = new SiteDetailViewModel(host, output); + + detail.Update(Site(php: "8.4", node: "22.11.0"), null, null, null, false, false); + Assert.Equal((true, "Pin PHP 8.4 to Project…"), (detail.ShowsPinPhp, detail.PinPhpText)); + Assert.Equal((true, "Pin Node 22 to Project…"), (detail.ShowsPinNode, detail.PinNodeText)); + Assert.Equal((true, "Pin Document Root (Project folder) to Project…"), (detail.ShowsPinRoot, detail.PinRootText)); + Assert.False(detail.ShowsMenuSeparator); + Assert.True(detail.HasMenu); + + // Pinned in the site's own folder: Unpin instead of Pin, with a separator before it. + detail.Update(Site(php: "8.4", node: "22", phpSource: Status.VersionSource.Project, phpFile: @"C:\code\shop\.flockrc"), null, null, null, false, false); + Assert.False(detail.ShowsPinPhp); + Assert.Equal((true, "Unpin PHP (.flockrc)…"), (detail.ShowsUnpinPhp, detail.UnpinPhpText)); + Assert.True(detail.ShowsMenuSeparator); + } + + [Fact] + public void PickingAVersionIsolatesItWithADraftUntilTheDaemonHasIt() + { + var detail = new SiteDetailViewModel(host, output); + var row = new SiteRowViewModel(Site(php: "8.4")); + var config = new FlockConfig { DefaultPhp = "8.4", Php = [new FlockConfig.PhpVersion { Version = "8.3" }, new FlockConfig.PhpVersion { Version = "8.4" }] }; + detail.Update(row.Snapshot, row, config, null, false, false); + + Assert.Equal(["Default (PHP 8.4)", "PHP 8.3", "PHP 8.4"], detail.Php.Items); + Assert.Equal(0, detail.Php.SelectedIndex); + Assert.Equal("The site follows Flock's default PHP version.", detail.Php.Tooltip); + + detail.Php.SelectedIndex = 1; + + var (resource, refreshAfter) = Assert.Single(host.Started); + Assert.Equal(("shop", false), (resource, refreshAfter)); + host.RunAll(); + Assert.Equal(new SiteChange(Php: "8.3"), Assert.Single(host.Changes).Change); + Assert.Equal("8.3", row.Php); + } + + [Fact] + public void AVersionIsolatedHereThatIsNoLongerInstalledStaysPickable() + { + var detail = new SiteDetailViewModel(host, output); + var config = new FlockConfig { Php = [new FlockConfig.PhpVersion { Version = "8.4" }] }; + + detail.Update(Site(php: "8.1", phpSource: Status.VersionSource.Isolated), null, config, null, false, false); + + Assert.Equal(["Default", "PHP 8.4", "PHP 8.1"], detail.Php.Items); + Assert.Equal(2, detail.Php.SelectedIndex); + Assert.Equal("PHP 8.1 is set for this site on this machine (isolated).", detail.Php.Tooltip); + } + + [Fact] + public void ControlsFollowingANewSnapshotDontActOnIt() + { + var detail = new SiteDetailViewModel(host, output); + var config = new FlockConfig { Php = [new FlockConfig.PhpVersion { Version = "8.3" }, new FlockConfig.PhpVersion { Version = "8.4" }] }; + detail.Update(Site(php: "8.3"), null, config, null, false, false); + // A picker the binding gives a new list may write its selection back meanwhile. + detail.Php.PropertyChanged += (_, _) => detail.Php.SelectedIndex = 2; + detail.PropertyChanged += (_, _) => (detail.Secured, detail.TypeIndex) = (true, 1); + + detail.Update(Site(php: "8.4", phpSource: Status.VersionSource.Isolated), null, config, null, false, false); + + Assert.Empty(host.Started); + Assert.Equal(2, detail.Php.SelectedIndex); + } + + [Fact] + public void ChooseFolderAsksForOneAndTheRootPickerSnapsBack() + { + var detail = new SiteDetailViewModel(host, output); + detail.Update(Site(rootSuggestions: ["public", "web"]), null, null, null, false, false); + Assert.Equal(["Automatic (Project folder)", "public", "web", "Choose Folder…"], detail.RootItems); + var changed = new List(); + detail.PropertyChanged += (_, args) => changed.Add(args.PropertyName); + + detail.RootIndex = 3; + + Assert.Equal(0, detail.RootIndex); + host.RunPosted(); + Assert.Contains(nameof(SiteDetailViewModel.RootIndex), changed); + Assert.Equal("ChooseRoot shop", Assert.Single(host.Calls(() => host.RunAll()))); + } + + [Fact] + public void HintsSayWhenVersionsAndRootsDisagree() + { + var detail = new SiteDetailViewModel(host, output); + var site = Site(php: "8.2", phpSource: Status.VersionSource.Isolated, phpPinned: "8.4", phpFile: @"C:\code\shop\.flockrc", + root: "web", rootSource: Status.VersionSource.Isolated, rootPinned: "public"); + + detail.Update(site, null, null, null, false, false); + + var php = Assert.Single(detail.VersionHints); + Assert.Equal(("PHP 8.2 is isolated here; the project pins 8.4 in .flockrc.", "Use .flockrc", true), (php.Text, php.ActionText, php.HasAction)); + var root = Assert.Single(detail.RootHints); + Assert.Equal("web is set here; the project sets public in .flockrc.", root.Text); + Assert.Equal("Use .flockrc (public)", detail.RootItems[0]); + Assert.Equal("", detail.RootEmptyNote); + + detail.Update(Site(rootState: Status.DocumentRootState.Empty, root: "public"), null, null, null, false, false); + Assert.Empty(detail.RootHints); + Assert.Equal("public has no index.php or index.html, so Flock shows a page instead of the site until it does.", detail.RootEmptyNote); + } + + [Fact] + public void ANodeAppShowsItsDevServerAndItsOutput() + { + var detail = new SiteDetailViewModel(host, output); + var site = Site(type: Status.SiteType.Node, server: new Status.SiteNodeServer + { + State = Status.NodeServerState.Starting, Port = 22417, Command = "npm run dev", Problem = "waiting for port 22417", Log = "node-shop", + }); + + detail.Update(site, null, null, null, false, false); + + Assert.Equal((true, false), (detail.IsNode, detail.IsPhp)); + Assert.Equal(("Caution", "Starting…", "waiting for port 22417"), (detail.DevServer.Tone, detail.DevServer.StateLabel, detail.DevServer.Problem)); + Assert.Equal((false, true, false), (detail.DevServer.CanStart, detail.DevServer.CanStop, detail.DevServer.CanRestart)); + Assert.Equal(new OutputSource("node-shop", true), detail.Output); + Assert.EndsWith("pin it (…) into .nvmrc.", detail.VersionsNote); + } + + [Fact] + public void ReverbShowsItsStateEnvAndOutput() + { + var detail = new SiteDetailViewModel(host, output); + var reverb = detail.Reverb; + + detail.Update(Site(reverbAvailable: true), null, null, null, false, false); + Assert.Equal((false, true, true), (reverb.IsOn, reverb.CanToggle, reverb.IsOff)); + Assert.Contains("ws://shop.test/app", reverb.OffNote); + Assert.Null(detail.Output); + + var row = new SiteRowViewModel(Site(secured: true, reverb: new Status.SiteReverb + { + State = Status.ReverbState.Running, Env = ["REVERB_HOST=shop.test", "REVERB_PORT=443"], Log = "reverb-shop", + })); + detail.Update(row.Snapshot, row, null, null, false, true); + Assert.Equal((true, true, "Running at wss://shop.test/app"), (reverb.IsOn, reverb.IsRunning, reverb.RunningText)); + Assert.Equal("REVERB_HOST=shop.test\nREVERB_PORT=443", reverb.Env); + // Its restart is in flight. + Assert.Equal((true, false), (reverb.IsRestarting, reverb.CanRestart)); + Assert.Equal(new OutputSource("reverb-shop", true), detail.Output); + + detail.Update(Site(reverb: new Status.SiteReverb { State = Status.ReverbState.Unavailable }), null, null, null, false, false); + Assert.Equal((true, "Reverb can't run."), (reverb.IsUnavailable, reverb.UnavailableText)); + Assert.Null(detail.Output); + } + + [Fact] + public void TurningReverbOffAsksTheDaemonAndShowsItAgainAfterwards() + { + var detail = new SiteDetailViewModel(host, output); + detail.Update(Site(reverb: new Status.SiteReverb { State = Status.ReverbState.Running }), null, null, null, false, false); + + detail.Reverb.IsOn = false; + + Assert.Equal(("shop", true), Assert.Single(host.Started)); + host.RunAll(); + Assert.Equal(new SiteChange(Reverb: false), Assert.Single(host.Changes).Change); + } + + [Fact] + public void FrankenPhpNeedsPhp82AndShowsItsState() + { + var detail = new SiteDetailViewModel(host, output); + var server = detail.Server; + + detail.Update(Site(php: "8.1"), null, null, null, false, false); + Assert.Equal((0, false, false), (server.ServerIndex, server.CanPickFrankenPhp, server.IsFrankenPhp)); + + detail.Update(Site(php: "8.4", server: Status.SiteServer.FrankenPhp, worker: Status.SiteServer.Octane, octane: true, + frankenPhp: new Status.SiteFrankenPhp { State = "running", Version = "1.13.0", PhpVersion = "8.4.14" }), null, null, null, true, false); + Assert.Equal((1, 1, true), (server.ServerIndex, server.ModeIndex, server.IsRunning)); + Assert.Equal("Running FrankenPHP 1.13.0 with PHP 8.4.14 · worker mode", server.RunningText); + Assert.Equal((true, false), (server.IsRestarting, server.CanRestart)); + Assert.Null(server.ModeTooltip); + + detail.Update(Site(php: "8.4", server: Status.SiteServer.FrankenPhp, + frankenPhp: new Status.SiteFrankenPhp { State = "installing", Progress = 0.42 }), null, null, null, false, false); + Assert.Equal((true, "Downloading FrankenPHP for PHP 8.4… 42%", false), (server.IsInstalling, server.InstallingText, server.IsProgressIndeterminate)); + Assert.NotNull(server.ModeTooltip); + + // Picking what already runs it does nothing; the other server changes it. + server.ServerIndex = 1; + Assert.Empty(host.Started); + server.ServerIndex = 0; + host.RunAll(); + Assert.Equal(new SiteChange(Server: Status.SiteServer.FastCgi), Assert.Single(host.Changes).Change); + } + + [Fact] + public void ShareFollowsItsStates() + { + var detail = new SiteDetailViewModel(host, output); + var share = detail.Share; + + detail.Update(Site(), null, null, null, false, false); + Assert.Equal((true, false), (share.IsIdle, share.CanShare)); + + detail.Update(Site(share: new Status.SiteShare { State = Status.ShareState.Building }), null, null, null, false, false); + Assert.Equal((true, "Building assets…", "Stop"), (share.IsStarting, share.StartingText, share.StopText)); + + detail.Update(Site(share: new Status.SiteShare + { + State = Status.ShareState.Running, Url = "https://a.trycloudflare.com", Allow = ["a@b.c"], Qr = "cXI=", Rebuilding = true, CanRebuild = true, + }), null, null, null, false, false); + Assert.Equal((true, true, "Stop sharing"), (share.IsRunning, share.HasQr, share.StopText)); + Assert.Equal(("Only for: a@b.c", "Rebuilding…", false), (share.Allowed, share.RebuildText, share.IsRebuildEnabled)); + + // Running without an address yet, or failed: why it stopped, and Try again. + detail.Update(Site(share: new Status.SiteShare { State = Status.ShareState.Failed }), null, null, null, false, false); + Assert.Equal((true, "The share stopped.", "Dismiss", false), (share.IsStopped, share.Failure, share.StopText, share.HasQr)); + } + + [Fact] + public void ASiteListsItsDomainsAndWorktrees() + { + var detail = new SiteDetailViewModel(host, output); + var status = new Status + { + Dns = new Status.DnsStatus { Wildcard = false, WildcardProblem = "a policy overrides it" }, + Sites = + [ + Site(domains: + [ + new Status.SiteDomain { Entry = "admin", Host = "admin.shop.test", Url = "https://admin.shop.test" }, + new Status.SiteDomain { Entry = "shop-2.test", Host = "shop-2.test", Alias = true, Url = "https://shop-2.test" }, + new Status.SiteDomain { Entry = "api", Host = "api.shop.test", Problem = "another site uses it" }, + ]), + new Status.Site + { + Name = "feature.shop", Domain = "feature.shop.test", Parent = "shop", Branch = "feature", + Worktree = new Status.WorktreeSetup { State = Status.WorktreeState.Running, Step = "composer install" }, + }, + ], + }; + + detail.Update(status.Sites[0], null, null, status, false, false); + + Assert.Equal(["admin.shop.test", "shop-2.test (alias)", "api.shop.test"], detail.Domains.Select(domain => domain.LinkText)); + Assert.Equal((true, false), (detail.Domains[2].HasProblem, detail.Domains[2].IsLink)); + Assert.Equal("api.shop.test: another site uses it", detail.Domains[2].Problem); + Assert.Equal("Subdomains only work when you add them here: a policy overrides it.", detail.WildcardNote); + Assert.True(detail.ShowsWildcardNote); + var worktree = Assert.Single(detail.Worktrees); + Assert.Equal(("feature", true, "composer install"), (worktree.Label, worktree.IsBusy, worktree.Step)); + Assert.False(detail.HasNoWorktrees); + + worktree.SelectCommand.Execute(null); + Assert.Equal("Select feature.shop", Assert.Single(host.Log)); + } + + [Fact] + public void AWorktreeSwitchesItsDatabaseAndThePickerSnapsBack() + { + var detail = new WorktreeDetailViewModel(host, output); + var worktree = new Status.Site + { + Name = "feature.shop", Domain = "feature.shop.test", Path = @"C:\code\shop-feature", Parent = "shop", Php = "8.4", + Worktree = new Status.WorktreeSetup { State = Status.WorktreeState.Ready, Database = "shop_feature", Log = "setup-feature" }, + }; + + detail.Update(worktree, new Status { Sites = [Site(), worktree] }); + + // Its own database in a mode left to flock.yml: none of the picker's. + Assert.Equal((-1, "shop_feature", true), (detail.DatabaseIndex, detail.CurrentDatabase, detail.CanSwitchDatabase)); + Assert.Equal(("feature", "None (detached HEAD)", true), (detail.Label, detail.Branch, detail.HasParent)); + Assert.Equal((true, true, false), (detail.IsReady, detail.HasLog, detail.IsBusy)); + + detail.DatabaseIndex = 2; + + Assert.Equal(-1, detail.DatabaseIndex); + host.RunAll(); + Assert.Equal("SwitchDatabase feature.shop fresh", Assert.Single(host.Log)); + } + + [Fact] + public void AWorktreeNotSetUpYetCanBeSetUp() + { + var detail = new WorktreeDetailViewModel(host, output); + + detail.Update(new Status.Site { Name = "fix.shop", Parent = "gone", Branch = "fix" }, new Status()); + + Assert.Equal((true, false, 0), (detail.IsNotSetUp, detail.CanSwitchDatabase, detail.DatabaseIndex)); + Assert.Equal("Set the worktree up first.", detail.DatabaseTooltip); + Assert.Equal((false, true), (detail.HasParent, detail.HasNoParent)); + detail.SetUpCommand.Execute(null); + host.RunAll(); + Assert.Equal("SetUp fix.shop False", Assert.Single(host.Log)); + } + + [Fact] + public void AProxyShowsItsCommandsAndTheirOutput() + { + var detail = new ProxyDetailViewModel(host, output); + var proxy = new Status.Proxy + { + Name = "s3", Domain = "s3.test", Upstream = "http://127.0.0.1:9000", + Command = new Status.ProxyCommand { Command = "docker compose up -d", StopCommand = "docker compose stop", Dir = @"C:\code\s3", State = Status.ProxyState.Stopping, Log = "proxy-s3" }, + }; + + detail.Update(proxy); + + Assert.Equal((true, "Caution", "Stopping…", ""), (detail.HasCommands, detail.Tone, detail.StateLabel, detail.StateProblem)); + Assert.Equal((true, false, false), (detail.HasStopLine, detail.CanStart, detail.CanStop)); + Assert.Equal(new OutputSource("proxy-s3", true), detail.Output); + Assert.Equal("Nothing answers at 127.0.0.1:9000 yet.", detail.NotRunningText); + Assert.DoesNotContain("Flock doesn't start the server", detail.Note); + + detail.Update(new Status.Proxy { Name = "s3", Domain = "s3.test", Upstream = "http://127.0.0.1:9000" }); + Assert.Equal((false, true), (detail.HasCommands, detail.HasNoCommands)); + Assert.Null(detail.Output); + Assert.Contains("Flock doesn't start the server", detail.Note); + + detail.Secured = true; + Assert.Equal(("s3", true), Assert.Single(host.Started)); + } + + [Fact] + public async Task TheOutputReadsTheLogItShows() + { + handler.Log = "starting\nready \n\n"; + + output.Show("node-shop", isLive: false); + await handler.LogRead.Task.WaitAsync(TimeSpan.FromSeconds(5)); + await WaitFor(() => output.Text == "starting\nready"); + + // Another log: its own text, from scratch. + handler.Log = ""; + output.Show("reverb-shop", isLive: false); + await WaitFor(() => output.Text == OutputViewModel.Empty); + Assert.Equal("reverb-shop", output.Log); + + output.Stop(); + Assert.False(output.IsPolling); + } + + private static async Task WaitFor(Func condition) + { + for (var attempt = 0; attempt < 100 && !condition(); attempt++) + { + await Task.Delay(20); + } + Assert.True(condition()); + } + + private static Status.Site Site(string php = "8.4", string? node = null, string phpSource = Status.VersionSource.Default, string phpPinned = "", + string phpFile = "", string root = ".", string rootSource = Status.VersionSource.Default, string rootPinned = "", string rootState = "", + List? rootSuggestions = null, string type = Status.SiteType.Php, Status.SiteNodeServer? server = null, bool secured = false, + bool reverbAvailable = false, Status.SiteReverb? reverb = null, string serverName = Status.SiteServer.FastCgi, Status.SiteShare? share = null, + List? domains = null) => new() + { + Name = "shop", Domain = "shop.test", Path = @"C:\code\shop", Php = php, PhpSource = phpSource, PhpPinned = phpPinned, PhpFile = phpFile, + Node = node, Root = root, RootSource = rootSource, RootPinned = rootPinned, RootState = rootState, RootSuggestions = rootSuggestions ?? [], + Type = type, NodeServer = server, Secured = secured, ReverbAvailable = reverbAvailable, Reverb = reverb, Server = serverName, Share = share, + Domains = domains ?? [], + }; + + private static Status.Site Site(string php, string server, Status.SiteFrankenPhp frankenPhp, string worker = "", bool octane = false) => new() + { + Name = "shop", Domain = "shop.test", Path = @"C:\code\shop", Php = php, Server = server, Worker = worker, OctaneAvailable = octane, FrankenPhp = frankenPhp, + }; + + /// The pane, as the details see it: what they start, change and open. + private sealed class Host(SitesService sites) : ISiteDetailHost + { + private readonly List> pending = []; + private readonly List posted = []; + + public SitesService Sites { get; } = sites; + public List<(string Resource, bool RefreshAfter)> Started { get; } = []; + public List<(string Site, SiteChange Change)> Changes { get; } = []; + public List Log { get; } = []; + + public void Start(string resource, Func action, bool refreshAfter = true) + { + Started.Add((resource, refreshAfter)); + pending.Add(action); + } + + public void RunAll() + { + foreach (var action in pending.ToList()) + { + action().GetAwaiter().GetResult(); + } + pending.Clear(); + } + + public void RunPosted() + { + posted.ForEach(action => action()); + posted.Clear(); + } + + /// What running something logged. + public List Calls(Action run) + { + Log.Clear(); + run(); + return Log; + } + + public Task UpdateSiteAsync(string site, SiteChange change, Func? draft) + { + draft?.Invoke(); + Changes.Add((site, change)); + return Task.CompletedTask; + } + + public void Post(Action action) => posted.Add(action); + public void Open(Status.Site site) => Log.Add($"Open {site.Name}"); + public void OpenUrl(string url) => Log.Add($"OpenUrl {url}"); + public void Reveal(string path) => Log.Add($"Reveal {path}"); + public void OpenLog(string log) => Log.Add($"OpenLog {log}"); + public void Copy(string text) => Log.Add($"Copy {text}"); + public void Select(string name) => Log.Add($"Select {name}"); + public Task RemoveSiteAsync(Status.Site site) => Logged($"RemoveSite {site.Name}"); + public Task ShowExtensionsAsync(Status.Site site) => Logged($"ShowExtensions {site.Name}"); + public Task PinAsync(Status.Site site, string tool, string version, string details) => Logged($"Pin {site.Name} {tool} {version}"); + public Task UnpinAsync(Status.Site site, string tool, string details) => Logged($"Unpin {site.Name} {tool}"); + public Task AddDomainAsync(Status.Site site) => Logged($"AddDomain {site.Name}"); + public Task RemoveDomainAsync(Status.Site site, Status.SiteDomain domain) => Logged($"RemoveDomain {site.Name} {domain.Entry}"); + public Task ChooseRootAsync(Status.Site site) => Logged($"ChooseRoot {site.Name}"); + public Task AddBoostGuidelineAsync(Status.Site site, bool missing) => Logged($"AddBoostGuideline {site.Name} {missing}"); + public Task StartShareAsync(Status.Site site) => Logged($"StartShare {site.Name}"); + public Task CopyQrAsync(string data) => Logged("CopyQr"); + public Task SaveQrAsync(string data, string site) => Logged($"SaveQr {site}"); + public Task NewWorktreeAsync(Status.Site site) => Logged($"NewWorktree {site.Name}"); + public Task RestartAsync(string site) => Logged($"Restart {site}"); + public Task RestartReverbAsync(string site) => Logged($"RestartReverb {site}"); + public Task RemoveWorktreeAsync(Status.Site worktree) => Logged($"RemoveWorktree {worktree.Name}"); + public Task SwitchDatabaseAsync(Status.Site worktree, string mode) => Logged($"SwitchDatabase {worktree.Name} {mode}"); + public Task SetUpWorktreeAsync(Status.Site worktree, bool again) => Logged($"SetUp {worktree.Name} {again}"); + public Task ShowSetupLogAsync(Status.Site worktree) => Logged($"ShowSetupLog {worktree.Name}"); + public Task RemoveProxyAsync(Status.Proxy proxy) => Logged($"RemoveProxy {proxy.Name}"); + public Task ChangeUpstreamAsync(Status.Proxy proxy) => Logged($"ChangeUpstream {proxy.Name}"); + public Task ChangeCommandAsync(Status.Proxy proxy) => Logged($"ChangeCommand {proxy.Name}"); + + private Task Logged(string call) + { + Log.Add(call); + return Task.CompletedTask; + } + } + + private sealed class Handler : HttpMessageHandler + { + public string Log { get; set; } = ""; + public TaskCompletionSource LogRead { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + if (request.RequestUri!.AbsolutePath.StartsWith("/v1/logs/services/", StringComparison.Ordinal)) + { + LogRead.TrySetResult(); + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(Log) }); + } + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("{}") }); + } + } +} diff --git a/windows/Flock.Tray.Tests/SitesWorkflowTests.cs b/windows/Flock.Tray.Tests/SitesWorkflowTests.cs index e2c25bc4..a95471a1 100644 --- a/windows/Flock.Tray.Tests/SitesWorkflowTests.cs +++ b/windows/Flock.Tray.Tests/SitesWorkflowTests.cs @@ -292,7 +292,9 @@ public void SiteSummaryUsesTheOrderedDiagnostics() Site("problem", "8.3", problem: "site problem", notice: "site notice", domains: [Domain("tenant1", "tenant1.problem.test", "domain problem")], server: Status.SiteServer.FrankenPhp, frankenPhp: Franken("franken problem")), Site("notice", "8.3", notice: "site notice"), Site("domain", "8.3", domains: [Domain("tenant1", "tenant1.domain.test", "domain problem")]), - Site("franken", "8.3", server: Status.SiteServer.FrankenPhp, frankenPhp: Franken("franken problem")) + Site("franken", "8.3", server: Status.SiteServer.FrankenPhp, frankenPhp: Franken("franken problem")), + Site("reverb", "8.3", reverb: new Status.SiteReverb { State = Status.ReverbState.Failed, Problem = "reverb problem" }), + Site("reverb-running", "8.3", reverb: new Status.SiteReverb { State = Status.ReverbState.Running }) ] }); @@ -300,6 +302,8 @@ public void SiteSummaryUsesTheOrderedDiagnostics() Assert.Equal("site notice", model.Sites[1].Problem); Assert.Equal("tenant1.domain.test: domain problem", model.Sites[2].Problem); Assert.Equal("franken problem", model.Sites[3].Problem); + Assert.Equal("reverb problem", model.Sites[4].Problem); + Assert.False(model.Sites[5].HasProblem); } [Fact] @@ -363,7 +367,7 @@ public async Task FailedSiteDraftCanRollBackWhileTheActionErrorStaysVisible() } private static Status.Site Site(string name, string php, string? problem = null, string? notice = null, - List? domains = null, string server = Status.SiteServer.FastCgi, Status.SiteFrankenPhp? frankenPhp = null) => new() + List? domains = null, string server = Status.SiteServer.FastCgi, Status.SiteFrankenPhp? frankenPhp = null, Status.SiteReverb? reverb = null) => new() { Name = name, Domain = name + ".test", @@ -372,7 +376,8 @@ private static Status.Site Site(string name, string php, string? problem = null, Notice = notice, Domains = domains ?? [], Server = server, - FrankenPhp = frankenPhp + FrankenPhp = frankenPhp, + Reverb = reverb }; private static Status.SiteDomain Domain(string entry, string host, string? problem = null) => new() { Entry = entry, Host = host, Problem = problem }; private static Status.SiteFrankenPhp Franken(string problem) => new() { State = "failed", Problem = problem }; diff --git a/windows/Flock.Tray/Features/Sites/SitesService.cs b/windows/Flock.Tray/Features/Sites/SitesService.cs index 551e79bc..b7c98319 100644 --- a/windows/Flock.Tray/Features/Sites/SitesService.cs +++ b/windows/Flock.Tray/Features/Sites/SitesService.cs @@ -47,6 +47,9 @@ public Task UpdateAsync(string site, SiteChange change, Cancel public Task RestartAsync(string site, CancellationToken token = default) => MutateAsync(Key(site), site, (_, cancellationToken) => client.RestartSiteAsync(site, cancellationToken), token); + public Task RestartReverbAsync(string site, CancellationToken token = default) => + MutateAsync(Key(site), site, (_, cancellationToken) => client.RestartReverbAsync(site, cancellationToken), token); + /// Adds a proxy, or changes its upstream (a port, host:port or URL). Site and proxy names never clash, so they share the site keys. public Task ProxyAsync(string name, string upstream, CancellationToken token = default) => ProxyAsync(name, upstream, null, token); diff --git a/windows/Flock.Tray/Settings/AddProxyForm.xaml b/windows/Flock.Tray/Settings/AddProxyForm.xaml new file mode 100644 index 00000000..e143330d --- /dev/null +++ b/windows/Flock.Tray/Settings/AddProxyForm.xaml @@ -0,0 +1,18 @@ + + + + + + + + + + + + + diff --git a/windows/Flock.Tray/Settings/AddProxyForm.xaml.cs b/windows/Flock.Tray/Settings/AddProxyForm.xaml.cs new file mode 100644 index 00000000..b7fc48a1 --- /dev/null +++ b/windows/Flock.Tray/Settings/AddProxyForm.xaml.cs @@ -0,0 +1,19 @@ +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +namespace Flock.Tray.Settings; + +/// The Add Proxy dialog's form: a name and an upstream, and optionally the commands that start its server. +internal sealed partial class AddProxyForm : UserControl +{ + public AddProxyForm(XamlRoot root, string tld, Func> pickFolder) + { + InitializeComponent(); + NameBox.PlaceholderText = $"my-app → my-app.{tld}"; + Commands.Show(null, pickFolder); + Ui.FitDialog(root, Scroller); + } + + public string ProxyName => NameBox.Text.Trim(); + public string Upstream => UpstreamBox.Text.Trim(); + public ProxyCommandsForm CommandFields => Commands; +} diff --git a/windows/Flock.Tray/Settings/DetailField.cs b/windows/Flock.Tray/Settings/DetailField.cs new file mode 100644 index 00000000..adeea65b --- /dev/null +++ b/windows/Flock.Tray/Settings/DetailField.cs @@ -0,0 +1,29 @@ +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +namespace Flock.Tray.Settings; + +/// +/// A labelled setting in a site's details: the label in a fixed column, so the controls line up, +/// and the control (its content) on the left of the rest. Its template is in DetailStyles.xaml. +/// +internal sealed partial class DetailField : ContentControl +{ + public static readonly DependencyProperty LabelProperty = DependencyProperty.Register( + nameof(Label), typeof(string), typeof(DetailField), new PropertyMetadata("")); + + /// Center for a one-line control; Top for a column of them, so the label lines up with the first. + public static readonly DependencyProperty LabelAlignmentProperty = DependencyProperty.Register( + nameof(LabelAlignment), typeof(VerticalAlignment), typeof(DetailField), new PropertyMetadata(VerticalAlignment.Center)); + + public string Label + { + get => (string)GetValue(LabelProperty); + set => SetValue(LabelProperty, value); + } + + public VerticalAlignment LabelAlignment + { + get => (VerticalAlignment)GetValue(LabelAlignmentProperty); + set => SetValue(LabelAlignmentProperty, value); + } +} diff --git a/windows/Flock.Tray/Settings/DetailStyles.xaml b/windows/Flock.Tray/Settings/DetailStyles.xaml new file mode 100644 index 00000000..6ba05573 --- /dev/null +++ b/windows/Flock.Tray/Settings/DetailStyles.xaml @@ -0,0 +1,126 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/windows/Flock.Tray/Settings/DetailWarning.xaml b/windows/Flock.Tray/Settings/DetailWarning.xaml new file mode 100644 index 00000000..dc9cda37 --- /dev/null +++ b/windows/Flock.Tray/Settings/DetailWarning.xaml @@ -0,0 +1,15 @@ + + + + + + + + + + diff --git a/windows/Flock.Tray/Settings/DetailWarning.xaml.cs b/windows/Flock.Tray/Settings/DetailWarning.xaml.cs new file mode 100644 index 00000000..93ab3a4b --- /dev/null +++ b/windows/Flock.Tray/Settings/DetailWarning.xaml.cs @@ -0,0 +1,38 @@ +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +namespace Flock.Tray.Settings; + +/// A warning in a site's details (or, as a hint, a note with an info icon), which wraps. +internal sealed partial class DetailWarning : UserControl +{ + public static readonly DependencyProperty TextProperty = DependencyProperty.Register( + nameof(Text), typeof(string), typeof(DetailWarning), new PropertyMetadata("", (control, _) => ((DetailWarning)control).Apply())); + + /// A hint: an info icon, and text that isn't selectable. + public static readonly DependencyProperty IsHintProperty = DependencyProperty.Register( + nameof(IsHint), typeof(bool), typeof(DetailWarning), new PropertyMetadata(false, (control, _) => ((DetailWarning)control).Apply())); + + public DetailWarning() + { + InitializeComponent(); + } + + public string Text + { + get => (string)GetValue(TextProperty); + set => SetValue(TextProperty, value); + } + + public bool IsHint + { + get => (bool)GetValue(IsHintProperty); + set => SetValue(IsHintProperty, value); + } + + private void Apply() + { + Label.Text = Text; + Label.IsTextSelectionEnabled = !IsHint; + Icon.Glyph = IsHint ? Ui.GlyphInfo : Ui.GlyphWarning; + } +} diff --git a/windows/Flock.Tray/Settings/DevServerSection.xaml b/windows/Flock.Tray/Settings/DevServerSection.xaml new file mode 100644 index 00000000..62c947f1 --- /dev/null +++ b/windows/Flock.Tray/Settings/DevServerSection.xaml @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/windows/Flock.Tray/Settings/SiteDetailView.xaml.cs b/windows/Flock.Tray/Settings/SiteDetailView.xaml.cs new file mode 100644 index 00000000..208932f7 --- /dev/null +++ b/windows/Flock.Tray/Settings/SiteDetailView.xaml.cs @@ -0,0 +1,11 @@ +using Microsoft.UI.Xaml.Controls; +namespace Flock.Tray.Settings; + +/// The selected site's details in Settings → Sites (its DataContext is a SiteDetailViewModel). +internal sealed partial class SiteDetailView : UserControl +{ + public SiteDetailView() + { + InitializeComponent(); + } +} diff --git a/windows/Flock.Tray/Settings/SiteExtensionsView.xaml b/windows/Flock.Tray/Settings/SiteExtensionsView.xaml new file mode 100644 index 00000000..dfb71366 --- /dev/null +++ b/windows/Flock.Tray/Settings/SiteExtensionsView.xaml @@ -0,0 +1,71 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + /// - /// A dialog's form in a scroll area as tall as the window leaves it (its title and buttons + /// Makes a dialog form's scroll area as tall as the window leaves it (its title and buttons /// aside), so the form stays usable in the smallest window: it scrolls instead of running /// under the buttons. Follows the window's size while the dialog is open. /// - public static ScrollViewer DialogForm(XamlRoot root, UIElement form) + public static void FitDialog(XamlRoot root, ScrollViewer scroller) { // The dialog's title, buttons and margins. const double chrome = 230; - var scroller = new ScrollViewer - { - Content = form, - VerticalScrollBarVisibility = ScrollBarVisibility.Auto, - HorizontalScrollBarVisibility = ScrollBarVisibility.Disabled, - // Room for the scrollbar, so it doesn't cover the fields. - Padding = new Thickness(0, 0, 16, 0), - }; + // Room for the scrollbar, so it doesn't cover the fields. + scroller.Padding = new Thickness(0, 0, 16, 0); void Fit(XamlRoot sender, XamlRootChangedEventArgs args) => scroller.MaxHeight = Math.Max(120, sender.Size.Height - chrome); Fit(root, null!); root.Changed += Fit; scroller.Unloaded += (_, _) => root.Changed -= Fit; - return scroller; } public static async Task Confirm(XamlRoot root, string question, string details, string action, bool destructive = false, UIElement? extra = null) diff --git a/windows/Flock.Tray/ViewModels/DetailViewModel.cs b/windows/Flock.Tray/ViewModels/DetailViewModel.cs new file mode 100644 index 00000000..11f3d9f0 --- /dev/null +++ b/windows/Flock.Tray/ViewModels/DetailViewModel.cs @@ -0,0 +1,124 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using Flock.Kit; +using Flock.Tray.Features.Sites; + +namespace Flock.Tray.ViewModels; + +/// +/// What the details of a site, worktree or proxy ask of the Sites pane: running an action of a site +/// (one at a time, the details shown again afterwards), its dialogs, and opening things. +/// +internal interface ISiteDetailHost +{ + SitesService Sites { get; } + + /// Runs an action of a site or proxy unless one is running; shows the details again afterwards unless refreshAfter is false. + void Start(string resource, Func action, bool refreshAfter = true); + + /// Changes a site, showing draft (which returns its rollback) until the daemon has it. + Task UpdateSiteAsync(string site, SiteChange change, Func? draft); + + /// Runs an action once the current input event is handled, e.g. a picker snapping back. + void Post(Action action); + + void Open(Status.Site site); + void OpenUrl(string url); + void Reveal(string path); + void OpenLog(string log); + void Copy(string text); + void Select(string name); + + Task RemoveSiteAsync(Status.Site site); + Task ShowExtensionsAsync(Status.Site site); + Task PinAsync(Status.Site site, string tool, string version, string details); + Task UnpinAsync(Status.Site site, string tool, string details); + Task AddDomainAsync(Status.Site site); + Task RemoveDomainAsync(Status.Site site, Status.SiteDomain domain); + Task ChooseRootAsync(Status.Site site); + Task AddBoostGuidelineAsync(Status.Site site, bool missing); + Task StartShareAsync(Status.Site site); + Task CopyQrAsync(string data); + Task SaveQrAsync(string data, string site); + Task NewWorktreeAsync(Status.Site site); + + /// Restarts a site's FrankenPHP or dev server, shown as busy meanwhile. + Task RestartAsync(string site); + + /// Restarts a site's Reverb server, shown as busy meanwhile. + Task RestartReverbAsync(string site); + + Task RemoveWorktreeAsync(Status.Site worktree); + Task SwitchDatabaseAsync(Status.Site worktree, string mode); + Task SetUpWorktreeAsync(Status.Site worktree, bool again); + Task ShowSetupLogAsync(Status.Site worktree); + + Task RemoveProxyAsync(Status.Proxy proxy); + Task ChangeUpstreamAsync(Status.Proxy proxy); + Task ChangeCommandAsync(Status.Proxy proxy); +} + +/// The output a detail shows: a server's log, read every second while it's live. +internal sealed record OutputSource(string Log, bool Live); + +/// +/// A part of the details over a status snapshot. A new snapshot makes every binding read again; +/// meanwhile the controls that follow it (a switch, a picker) don't act on what they're given. +/// +internal abstract class DetailViewModel : ObservableObject +{ + /// The bindings are reading a new snapshot. + protected bool Refreshing { get; private set; } + + /// Every binding reads again; first the given properties (the lists a picker's selection is in). + protected void Refresh(params string[] first) + { + Refreshing = true; + try + { + foreach (var property in first) + { + OnPropertyChanged(property); + } + OnPropertyChanged(string.Empty); + } + finally + { + Refreshing = false; + } + } + + /// One binding reads again, e.g. a picker snapping back to what the snapshot says. + protected void RefreshOne(string property) + { + Refreshing = true; + try + { + OnPropertyChanged(property); + } + finally + { + Refreshing = false; + } + } + + /// The list, or the one shown when it has the same items (so a picker keeps its selection). + protected static IReadOnlyList Same(IReadOnlyList shown, List items) => + shown.SequenceEqual(items) ? shown : items; + + /// A server's state as a dot's tone (see ToneBrushConverter). + protected static string ToneName(Ui.Tone tone) => tone.ToString(); + + /// "22.11.0" → "22"; pins and isolations are majors. + protected static string Major(string version) => version.Split('.')[0]; +} + +/// A note that wraps, with an action under it (or none). +internal sealed partial class HintViewModel(string text, string? action, Action? run) : ObservableObject +{ + public string Text { get; } = text; + public string ActionText { get; } = action ?? ""; + public bool HasAction => action is not null && run is not null; + + [CommunityToolkit.Mvvm.Input.RelayCommand] + private void Run() => run?.Invoke(); +} diff --git a/windows/Flock.Tray/ViewModels/OutputViewModel.cs b/windows/Flock.Tray/ViewModels/OutputViewModel.cs new file mode 100644 index 00000000..13de6d2f --- /dev/null +++ b/windows/Flock.Tray/ViewModels/OutputViewModel.cs @@ -0,0 +1,119 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Flock.Tray.Features.Sites; + +namespace Flock.Tray.ViewModels; + +/// +/// The output of a server Flock runs (a Node.js site's dev server, a site's Reverb server, a +/// proxy's command): the last lines of its log, read every second while the server runs, with +/// Clear and Open Log. The Sites pane keeps one for whichever details show output, so the text +/// survives the status updates. +/// +internal sealed partial class OutputViewModel(SitesService sites, Action openLog) : ObservableObject +{ + public const string Empty = "No output yet."; + + private string text = ""; + private bool live; + private CancellationTokenSource? polling; + + /// The log shown, a name for /v1/logs/services/{name}. + public string? Log { get; private set; } + + public string Text + { + get => text; + private set => SetProperty(ref text, value); + } + + /// Whether it's read every second. + public bool IsPolling => polling is not null; + + /// Shows a log, and reads it now, and every second while live (the server starts or runs). + public void Show(string log, bool isLive) + { + var changed = log != Log; + Log = log; + if (changed) + { + Text = ""; + } + if (changed || isLive != live || polling is null) + { + live = isLive; + Restart(); + } + } + + /// Stops reading, while the pane is hidden or shows something without output. + public void Stop() + { + polling?.Cancel(); + polling = null; + } + + private void Restart() + { + Stop(); + polling = new CancellationTokenSource(); + _ = PollAsync(polling.Token); + } + + private async Task PollAsync(CancellationToken token) + { + try + { + do + { + await ReadAsync(token); + if (!live) + { + return; + } + await Task.Delay(TimeSpan.FromSeconds(1), token); + } while (!token.IsCancellationRequested); + } + catch (OperationCanceledException) + { + } + catch (Exception error) + { + App.Log(error); + } + } + + private async Task ReadAsync(CancellationToken token) + { + if (Log is not { } name) + { + return; + } + var output = await sites.ServiceLogAsync(name, token); + if (token.IsCancellationRequested || name != Log) + { + return; + } + var trimmed = (output ?? "").TrimEnd(); + Text = trimmed.Length == 0 ? Empty : trimmed; + } + + [RelayCommand(AllowConcurrentExecutions = true)] + private async Task ClearAsync() + { + // Only for the log still shown: another may have been selected while it was cleared. + if (Log is { } name && await sites.ClearServiceLogAsync(name) && name == Log) + { + Text = Empty; + } + } + + [RelayCommand] + private void OpenLog() + { + if (Log is { } name) + { + openLog(name); + } + } +} diff --git a/windows/Flock.Tray/ViewModels/ProxyDetailViewModel.cs b/windows/Flock.Tray/ViewModels/ProxyDetailViewModel.cs new file mode 100644 index 00000000..141fcb3e --- /dev/null +++ b/windows/Flock.Tray/ViewModels/ProxyDetailViewModel.cs @@ -0,0 +1,143 @@ +using CommunityToolkit.Mvvm.Input; +using Flock.Kit; + +namespace Flock.Tray.ViewModels; + +/// +/// The selected proxy: its address and actions, then its server, HTTPS and upstream, and the +/// commands Flock starts its server with (its state, Start and Stop, and its output), or a way to +/// add them. +/// +internal sealed partial class ProxyDetailViewModel(ISiteDetailHost host, OutputViewModel output) : DetailViewModel +{ + private Status.Proxy proxy = new(); + private bool securedShown; + + public bool IsShown { get; private set; } + public OutputViewModel OutputPanel { get; } = output; + + // Header. + public string Name => proxy.Name; + public string Url => proxy.Url ?? ""; + public bool HasUrl => proxy.Url is not null; + public string OpenLabel => $"Open {proxy.Url ?? proxy.Domain}"; + public string Problem => proxy.Problem ?? ""; + + // Proxy: its server, HTTPS and upstream. + public bool Running => proxy.Running; + public string ServerLabel => proxy.ServerLabel; + public bool IsNotRunning => !proxy.Running; + public string NotRunningText => $"Nothing answers at {proxy.UpstreamLabel} yet."; + + public bool Secured + { + get => securedShown; + set + { + if (Refreshing || value == securedShown) + { + return; + } + // Shown again afterwards, so a refused change snaps back. + securedShown = value; + var name = proxy.Name; + host.Start(name, () => host.Sites.SecureProxyAsync(name, value)); + } + } + + public string Upstream => proxy.Upstream; + public string Note + { + get + { + var starts = proxy.Command is null ? " Flock doesn't start the server, so start it as usual, or give it commands below." : ""; + return $"Every request passes to the upstream with its Host header, WebSocket upgrades (hot reload) included.{starts} Until it answers, {proxy.Domain} shows a page saying so."; + } + } + + // Commands. + private Status.ProxyCommand? Command => proxy.Command; + public bool HasCommands => Command is not null; + public bool HasNoCommands => Command is null; + public string Tone => ToneName(Command?.State switch + { + Status.ProxyState.Running => Ui.Tone.Success, + Status.ProxyState.Starting or Status.ProxyState.Stopping => Ui.Tone.Caution, + Status.ProxyState.Failed => Ui.Tone.Critical, + _ => Ui.Tone.Neutral, + }); + public string StateLabel => Command?.StateLabel ?? ""; + /// Why it's still starting; a failure says why in its state. + public string StateProblem => Command is { State: Status.ProxyState.Starting } command ? command.Problem : ""; + public string StartLine => Command?.Command ?? ""; + public string StopLine => Command?.StopCommand ?? ""; + public bool HasStopLine => Command is { StopCommand.Length: > 0 }; + public string Folder => Command?.Dir ?? ""; + public bool CanStart => Command?.CanStart == true; + public bool CanStop => Command?.CanStop == true; + + public OutputSource? Output => Command is { } command + ? new OutputSource(command.Log, command.State is Status.ProxyState.Starting or Status.ProxyState.Running or Status.ProxyState.Stopping) + : null; + + public void Hide() + { + if (IsShown) + { + IsShown = false; + OnPropertyChanged(nameof(IsShown)); + } + } + + public void Update(Status.Proxy value) + { + proxy = value; + IsShown = true; + securedShown = value.Secured; + Refresh(); + } + + [RelayCommand] + private void Open() + { + if (proxy.Url is { } url) + { + host.OpenUrl(url); + } + } + + [RelayCommand] + private void Remove() + { + var current = proxy; + host.Start(current.Name, () => host.RemoveProxyAsync(current)); + } + + [RelayCommand] + private void ChangeUpstream() + { + var current = proxy; + host.Start(current.Name, () => host.ChangeUpstreamAsync(current)); + } + + [RelayCommand] + private void ChangeCommands() + { + var current = proxy; + host.Start(current.Name, () => host.ChangeCommandAsync(current)); + } + + [RelayCommand] + private void StartServer() + { + var name = proxy.Name; + host.Start(name, () => host.Sites.StartProxyAsync(name)); + } + + [RelayCommand] + private void StopServer() + { + var name = proxy.Name; + host.Start(name, () => host.Sites.StopProxyAsync(name)); + } +} diff --git a/windows/Flock.Tray/ViewModels/SiteDetailViewModel.cs b/windows/Flock.Tray/ViewModels/SiteDetailViewModel.cs new file mode 100644 index 00000000..7d45ac4d --- /dev/null +++ b/windows/Flock.Tray/ViewModels/SiteDetailViewModel.cs @@ -0,0 +1,470 @@ +using CommunityToolkit.Mvvm.Input; +using Flock.Kit; +using Flock.Tray.Features.Sites; +using IOPath = System.IO.Path; + +namespace Flock.Tray.ViewModels; + +/// +/// The selected site's details: its address, domains and document root, its PHP and Node.js +/// versions and what serves it, Reverb, Laravel Boost and its worktrees. Each version picker offers +/// the project's pinned version (or the default) first, then the installed versions, which isolate +/// the site on this machine; the document root picker works the same way. The "…" menu pins a +/// version or the document root into the project or removes it again. +/// +internal sealed partial class SiteDetailViewModel : DetailViewModel +{ + private readonly ISiteDetailHost host; + private Status.Site site = new(); + private Status? status; + private int typeIndex; + private int typeShown; + private bool securedShown; + private List rootFolders = []; + private int rootIndex; + private int rootShown; + + public SiteDetailViewModel(ISiteDetailHost host, OutputViewModel output) + { + this.host = host; + Php = new VersionPickerViewModel("PHP"); + Node = new VersionPickerViewModel("Node"); + Share = new ShareSectionViewModel(host); + Server = new SiteServerSectionViewModel(host); + Reverb = new ReverbSectionViewModel(host, output); + DevServer = new DevServerSectionViewModel(host, output); + } + + public bool IsShown { get; private set; } + public Status.Site Site => site; + public SiteRowViewModel? Row { get; private set; } + + // Header: name, address and folder, and the site's actions. + public string Name => site.Name; + public bool IsProjectFolder => site.Parked; + public string Url => site.Url ?? ""; + public bool HasUrl => site.Url is not null; + public string Path => site.Path; + public string OpenLabel => $"Open {site.Url ?? site.Domain}"; + public bool IsPhp => !site.IsNode; + public bool IsNode => site.IsNode; + public bool CanRemove => !site.Parked; + public string Problem => site.Problem ?? ""; + + // The "…" menu: pin and unpin items appear only when there is something to pin or unpin. + public bool ShowsPinPhp => site.Php.Length > 0 && !(site.PhpSource == Status.VersionSource.Project && site.PhpPinnedHere); + public string PinPhpText => $"Pin PHP {site.Php} to Project…"; + public bool ShowsPinNode => NodeMajor.Length > 0 && !(site.NodeSource == Status.VersionSource.Project && site.NodePinnedHere); + public string PinNodeText => $"Pin Node {NodeMajor} to Project…"; + public bool ShowsPinRoot => !site.IsNode && site.RootState is "" or Status.DocumentRootState.Empty && site.RootSource != Status.VersionSource.Project; + public string PinRootText => $"Pin Document Root ({RootLabel(site.Root)}) to Project…"; + public bool ShowsUnpinPhp => site.PhpPinnedHere; + public string UnpinPhpText => $"Unpin PHP ({IOPath.GetFileName(site.PhpFile)})…"; + public bool ShowsUnpinNode => site.NodePinnedHere; + public string UnpinNodeText => $"Unpin Node ({IOPath.GetFileName(site.NodeFile)})…"; + public bool ShowsUnpinRoot => site.RootPinnedHere; + private bool HasPins => ShowsPinPhp || ShowsPinNode || ShowsPinRoot; + private bool HasUnpins => ShowsUnpinPhp || ShowsUnpinNode || ShowsUnpinRoot; + public bool ShowsMenuSeparator => HasPins && HasUnpins; + public bool HasMenu => HasPins || HasUnpins; + + // Site: its type, HTTPS, the domains besides its own and the document root. + public int TypeIndex + { + get => typeShown; + set + { + if (Refreshing || value < 0) + { + return; + } + typeShown = value; + if (value == typeIndex) + { + return; + } + var change = value == 1 ? Status.SiteType.Node : Status.SiteType.Php; + var name = site.Name; + host.Start(name, () => host.UpdateSiteAsync(name, new SiteChange(Type: change), null), false); + } + } + + public bool Secured + { + get => securedShown; + set + { + if (Refreshing || value == securedShown) + { + return; + } + securedShown = value; + var (name, row) = (site.Name, Row); + host.Start(name, () => host.UpdateSiteAsync(name, new SiteChange(Secured: value), () => row?.SetDraftSecured(value)), false); + } + } + + public IReadOnlyList Domains { get; private set; } = []; + public bool HasDomains => Domains.Count > 0; + public bool ShowsWildcardNote => status?.Dns is { Wildcard: false, WildcardProblem.Length: > 0 }; + public string WildcardNote => $"Subdomains only work when you add them here: {status?.Dns.WildcardProblem}."; + + public IReadOnlyList RootItems { get; private set; } = []; + public int RootIndex + { + get => rootShown < RootItems.Count ? rootShown : -1; + set + { + if (Refreshing || value < 0 || value == rootIndex) + { + return; + } + var current = site; + if (value == RootItems.Count - 1) + { + // Choose Folder…: the picker shows the root until one is chosen. + rootShown = rootIndex; + host.Post(() => RefreshOne(nameof(RootIndex))); + host.Start(current.Name, () => host.ChooseRootAsync(current)); + return; + } + rootShown = value; + var root = value == 0 ? "" : rootFolders[value - 1]; + host.Start(current.Name, () => host.Sites.UpdateAsync(current.Name, new SiteChange(Root: root))); + } + } + + public string RootTooltip => site.RootSource switch + { + Status.VersionSource.Isolated => $"{RootLabel(site.Root)} is set for this site on this machine.", + Status.VersionSource.Project => $"The project sets {RootLabel(site.Root)} in .flockrc.", + _ => "Flock serves the first of public\\, www\\, web\\ and webroot\\ with an index.php, else the project folder." + }; + + /// Why the site isn't served from its root, and a root set here over the project's. + public IReadOnlyList RootHints { get; private set; } = []; + public bool HasRootHints => RootHints.Count > 0; + public string RootEmptyNote => site.RootState == Status.DocumentRootState.Empty + ? $"{RootLabel(site.Root)} has no index.php or index.html, so Flock shows a page instead of the site until it does." + : ""; + + // Versions: PHP and Node.js (a Node.js app has no PHP), with what pins or isolates them. + public VersionPickerViewModel Php { get; } + public VersionPickerViewModel Node { get; } + public IReadOnlyList VersionHints { get; private set; } = []; + public bool HasVersionHints => VersionHints.Count > 0; + public string VersionsNote => site.IsNode + ? "A version picked here applies on this machine only. To set it for everyone working on the project, pin it (…) into .nvmrc." + : "A version picked here applies on this machine only. To set it for everyone working on the project, pin it (…): PHP goes into .flockrc, Node.js into .nvmrc."; + + public ShareSectionViewModel Share { get; } + public SiteServerSectionViewModel Server { get; } + public DevServerSectionViewModel DevServer { get; } + public ReverbSectionViewModel Reverb { get; } + + // Laravel Boost: the project uses it, and Flock's guideline is missing from it or outdated. + public bool ShowsBoost => !site.IsNode && site.Boost is Status.BoostState.Missing or Status.BoostState.Outdated; + private bool BoostMissing => site.Boost == Status.BoostState.Missing; + public string BoostText => BoostMissing + ? "The project uses Laravel Boost. Adding Flock to its guidelines tells AI agents that Flock serves the app and picks its PHP version." + : "Flock's guideline in the project's Boost guidelines is outdated."; + public string BoostAction => BoostMissing ? "Add Flock to its guidelines…" : "Update it…"; + + // Worktrees: each selects it, and New Worktree. + public IReadOnlyList Worktrees { get; private set; } = []; + public bool HasNoWorktrees => Worktrees.Count == 0; + public string WorktreesNote => $"Every git worktree of the project is served at .{site.Domain}, with its own dependencies and .env."; + + /// The output shown: the dev server's of a Node.js site, else Reverb's. + public OutputSource? Output => site.IsNode ? DevServer.Output : Reverb.Output; + + private string NodeMajor => Major(site.Node ?? ""); + + public void Hide() + { + if (IsShown) + { + IsShown = false; + OnPropertyChanged(nameof(IsShown)); + } + } + + /// Shows a site (row keeps the values picked here until the daemon has them). + public void Update(Status.Site value, SiteRowViewModel? row, FlockConfig? config, Status? current, bool restarting, bool restartingReverb) + { + site = value; + Row = row; + status = current; + IsShown = true; + typeIndex = value.IsNode ? 1 : 0; + typeShown = typeIndex; + securedShown = row?.Secured ?? value.Secured; + + Domains = value.Domains.Select(domain => new DomainItemViewModel(host, value, domain)).ToList(); + + var follow = value.RootPinnedHere ? $"Use .flockrc ({RootLabel(value.RootPinned)})" + : value.RootSource != Status.VersionSource.Default ? "Automatic" + : value.RootState == Status.DocumentRootState.NotFound ? "Automatic (none found)" + : $"Automatic ({RootLabel(value.Root)})"; + rootFolders = value.RootSuggestions.ToList(); + if (value.RootSource == Status.VersionSource.Isolated && !rootFolders.Contains(value.Root)) + { + rootFolders.Add(value.Root); + } + RootItems = Same(RootItems, [follow, .. rootFolders.Select(RootLabel), "Choose Folder…"]); + rootIndex = value.RootSource == Status.VersionSource.Isolated ? rootFolders.IndexOf(value.Root) + 1 : 0; + rootShown = rootIndex; + RootHints = RootHintsOf(value); + + Php.Update(row?.Php ?? value.Php, value.PhpSource, value.PhpPinned, value.PhpFile, config?.DefaultPhp ?? "", + config?.Php.Select(p => p.Version).ToList() ?? [], + version => SetVersion(new SiteChange(Php: version), () => row?.SetDraftPhp(version))); + Node.Update(row?.Node ?? Major(value.Node ?? ""), value.NodeSource, Major(value.NodePinned), value.NodeFile, config?.DefaultNode ?? "", + config?.NodeMajors ?? [], + version => SetVersion(new SiteChange(Node: version), () => row?.SetDraftNode(version))); + VersionHints = VersionHintsOf(value); + + Share.Update(value); + Server.Update(value, row, restarting); + DevServer.Update(value); + Reverb.Update(value, row, restartingReverb); + Worktrees = (current?.WorktreesOf(value.Name) ?? []).Select(worktree => new WorktreeItemViewModel(host, worktree)).ToList(); + + Refresh(nameof(RootItems)); + } + + private void SetVersion(SiteChange change, Func draft) + { + var name = site.Name; + host.Start(name, () => host.UpdateSiteAsync(name, change, draft), false); + } + + private List RootHintsOf(Status.Site value) + { + if (value.RootState == Status.DocumentRootState.NotFound) + { + return [new HintViewModel( + "No public\\, www\\, web\\ or webroot\\ with an index.php was found, so Flock shows a page instead of the site. Choose the folder with the app's index.php.", + null, null)]; + } + if (value.RootState == Status.DocumentRootState.Missing) + { + return [new HintViewModel($"Choose the folder with the app's index.php, or create {value.Root}.", null, null)]; + } + if (value.RootState != Status.DocumentRootState.Empty && value.RootSource == Status.VersionSource.Isolated && value.RootPinnedHere) + { + return [new HintViewModel($"{RootLabel(value.Root)} is set here; the project sets {RootLabel(value.RootPinned)} in .flockrc.", + "Use .flockrc", () => host.Start(value.Name, () => host.Sites.UpdateAsync(value.Name, new SiteChange(Root: ""))))]; + } + return []; + } + + /// Notes when versions disagree: an isolation over the project's pin, .flockrc over .nvmrc. + private List VersionHintsOf(Status.Site value) + { + var hints = new List(); + if (value.PhpSource == Status.VersionSource.Isolated && value.PhpPinned.Length > 0 && value.PhpPinned != value.Php) + { + var file = IOPath.GetFileName(value.PhpFile); + hints.Add(new HintViewModel($"PHP {value.Php} is isolated here; the project pins {value.PhpPinned} in {file}.", + $"Use {file}", () => host.Start(value.Name, () => host.Sites.UpdateAsync(value.Name, new SiteChange(Php: ""))))); + } + if (value.NodeSource == Status.VersionSource.Isolated && value.NodePinned.Length > 0 && Major(value.NodePinned) != Major(value.Node ?? "")) + { + var file = IOPath.GetFileName(value.NodeFile); + hints.Add(new HintViewModel($"Node {value.Node} is isolated here; the project pins {value.NodePinned} in {file}.", + $"Use {file}", () => host.Start(value.Name, () => host.Sites.UpdateAsync(value.Name, new SiteChange(Node: ""))))); + } + if (value.NodeShadowed.Length > 0) + { + hints.Add(new HintViewModel($".flockrc's Node {value.NodePinned} wins over {value.NodeShadowed}.", null, null)); + } + return hints; + } + + /// "src/www", or "Project folder" for ".". + internal static string RootLabel(string root) => root == "." ? "Project folder" : root; + + [RelayCommand] + private void Open() => host.Open(site); + + [RelayCommand] + private void Reveal() => host.Reveal(site.Path); + + [RelayCommand] + private void ShowExtensions() + { + var current = site; + host.Start("extensions:" + current.Name, () => host.ShowExtensionsAsync(current)); + } + + [RelayCommand] + private void Remove() + { + var current = site; + host.Start(current.Name, () => host.RemoveSiteAsync(current)); + } + + [RelayCommand] + private void PinPhp() + { + var current = site; + host.Start(current.Name, () => host.PinAsync(current, "php", current.Php, + $"Writes php={current.Php} to .flockrc in {current.Path}. Commit it, and everyone using Flock on this project gets PHP {current.Php}.")); + } + + [RelayCommand] + private void PinNode() + { + var current = site; + var major = Major(current.Node ?? ""); + var file = current.NodePinnedHere ? IOPath.GetFileName(current.NodeFile) : ".nvmrc"; + host.Start(current.Name, () => host.PinAsync(current, "node", major, + $"Writes {major} to {file} in {current.Path}. Commit it, and Flock, nvm and other Node tools use Node {major} for this project.")); + } + + [RelayCommand] + private void PinRoot() + { + var current = site; + host.Start(current.Name, () => host.PinAsync(current, "root", current.Root, + $"Writes root={current.Root} to .flockrc in {current.Path}. Commit it, and everyone using Flock on this project serves it from {RootLabel(current.Root)}.")); + } + + [RelayCommand] + private void UnpinPhp() + { + var current = site; + host.Start(current.Name, () => host.UnpinAsync(current, "php", + $"Removes the PHP version from {current.PhpFile}. The site then uses Flock's default PHP.")); + } + + [RelayCommand] + private void UnpinNode() + { + var current = site; + host.Start(current.Name, () => host.UnpinAsync(current, "node", + $"Removes the Node.js version from .flockrc and deletes .nvmrc / .node-version in {current.Path}. The site then uses Flock's default Node.js.")); + } + + [RelayCommand] + private void UnpinRoot() + { + var current = site; + host.Start(current.Name, () => host.UnpinAsync(current, "root", + $"Removes root= from .flockrc in {current.Path}. Flock then finds the document root itself.")); + } + + [RelayCommand] + private void AddDomain() + { + var current = site; + host.Start(current.Name, () => host.AddDomainAsync(current)); + } + + [RelayCommand] + private void AddBoostGuideline() + { + var (current, missing) = (site, BoostMissing); + host.Start(current.Name, () => host.AddBoostGuidelineAsync(current, missing)); + } + + [RelayCommand] + private void NewWorktree() + { + var current = site; + host.Start(current.Name, () => host.NewWorktreeAsync(current)); + } +} + +/// +/// A version picker: first the project's pinned version ("Use .nvmrc (Node 20)") or the default, +/// which follow the project; then the installed versions, which isolate the site. +/// +internal sealed class VersionPickerViewModel(string label) : DetailViewModel +{ + private List versions = []; + private int shown; + private Action pick = _ => { }; + + public IReadOnlyList Items { get; private set; } = []; + public string Tooltip { get; private set; } = ""; + + public int SelectedIndex + { + get => shown < Items.Count ? shown : -1; + set + { + if (Refreshing || value < 0 || value == shown) + { + return; + } + shown = value; + pick(value == 0 ? "" : versions[value - 1]); + } + } + + public void Update(string current, string source, string pinned, string file, string defaultVersion, List installed, Action onPick) + { + pick = onPick; + // No default yet while the first run installs it. + var follow = pinned.Length > 0 ? $"Use {Path.GetFileName(file)} ({label} {pinned})" + : defaultVersion.Length > 0 ? $"Default ({label} {defaultVersion})" + : "Default"; + versions = installed.ToList(); + if (source == Status.VersionSource.Isolated && current.Length > 0 && !versions.Contains(current)) + { + versions.Add(current); // isolated to a version that isn't installed (any more) + } + Items = Same(Items, [follow, .. versions.Select(version => $"{label} {version}")]); + shown = source == Status.VersionSource.Isolated ? versions.IndexOf(current) + 1 : 0; + Tooltip = source switch + { + Status.VersionSource.Isolated => $"{label} {current} is set for this site on this machine (isolated).", + Status.VersionSource.Project => $"The project pins {label} {pinned} in {Path.GetFileName(file)}.", + _ => $"The site follows Flock's default {label} version." + }; + Refresh(nameof(Items)); + } +} + +/// One of a site's extra domains: its link (or why it isn't served) and Remove. +internal sealed partial class DomainItemViewModel(ISiteDetailHost host, Status.Site site, Status.SiteDomain domain) +{ + public string Problem => domain.Problem is { } problem ? $"{domain.Host}: {problem}" : ""; + public bool HasProblem => domain.Problem is not null; + public bool IsLink => !HasProblem && domain.Url is { Length: > 0 }; + public bool IsText => !HasProblem && !IsLink; + public string Host => domain.Host; + public string LinkText => domain.Alias ? $"{domain.Host} (alias)" : domain.Host; + public string LinkTooltip => $"Open {domain.Url}"; + public string RemoveLabel => $"Remove {domain.Host}"; + + [RelayCommand] + private void Open() + { + if (domain.Url is { Length: > 0 } url) + { + host.OpenUrl(url); + } + } + + [RelayCommand] + private void Remove() => host.Start(site.Name, () => host.RemoveDomainAsync(site, domain)); +} + +/// A worktree in its site's details: a link that selects it, and its setup or its own database. +internal sealed partial class WorktreeItemViewModel(ISiteDetailHost host, Status.Site worktree) +{ + public string Label => worktree.WorktreeLabel; + public string Path => worktree.Path; + public string Domain => worktree.Domain; + public bool IsBusy => worktree.Worktree is { IsBusy: true }; + public string Step => worktree.Worktree is { Step.Length: > 0 } setup ? setup.Step : "Setting up…"; + public bool IsFailed => worktree.Worktree is { IsBusy: false, IsFailed: true }; + public bool HasOwnDatabase => worktree.Worktree is { IsBusy: false, IsFailed: false, HasOwnDatabase: true }; + public string DatabaseLabel => worktree.Worktree is { DbMode.Length: > 0 } setup ? setup.DbMode : "own database"; + + [RelayCommand] + private void Select() => host.Select(worktree.Name); +} diff --git a/windows/Flock.Tray/ViewModels/SiteExtensionsViewModel.cs b/windows/Flock.Tray/ViewModels/SiteExtensionsViewModel.cs new file mode 100644 index 00000000..7e9ca603 --- /dev/null +++ b/windows/Flock.Tray/ViewModels/SiteExtensionsViewModel.cs @@ -0,0 +1,97 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Flock.Kit; +using Flock.Tray.Features.Sites; + +namespace Flock.Tray.ViewModels; + +/// +/// A site's PHP extensions: what composer.json and the locked packages require, what a Laravel +/// app's configuration shows it uses without requiring it, and whether Flock's PHP has each. +/// Adding one to composer.json is confirmed inline (only one dialog can be open at a time). +/// +internal sealed partial class SiteExtensionsViewModel(SitesService sites, Status.Site site, Action> start, CancellationToken token) + : ObservableObject +{ + private ExtensionReport? report; + private string? error; + private string? confirming; + private string? adding; + + public string Error => error ?? ""; + public bool HasError => error is not null; + public bool IsLoading => report is null && error is null; + public bool HasReport => report is not null; + public string Summary => report is null ? "" : report.Laravel ? $"PHP {report.Php} · Laravel app" : $"PHP {report.Php}"; + public bool HasNoExtensions => report is { Extensions.Count: 0 }; + public IReadOnlyList Extensions { get; private set; } = []; + public string SitePath => site.Path; + + /// Loads the report; false when it was retired (another site's was asked for meanwhile). + public async Task LoadAsync() + { + (report, error) = await sites.SiteExtensionsAsync(site.Name, token); + if (report is null && error is null) + { + return false; + } + Render(); + return true; + } + + internal void Confirm(string name) + { + confirming = name; + Render(); + } + + internal void Cancel() + { + confirming = null; + Render(); + } + + internal void Require(string name) => start(() => AddAsync(name)); + + private async Task AddAsync(string name) + { + confirming = null; + adding = name; + Render(); + (var fresh, error) = await sites.RequireExtensionAsync(site.Name, name, token); + report = fresh ?? report; + adding = null; + Render(); + } + + private void Render() + { + Extensions = report?.Extensions.Select(extension => new ExtensionItemViewModel(this, report.Php, extension, + adding == extension.Name, confirming == extension.Name, adding is null)).ToList() ?? []; + OnPropertyChanged(string.Empty); + } +} + +/// An extension in the list: whether Flock's PHP has it, what requires it, and Add to composer.json. +internal sealed partial class ExtensionItemViewModel(SiteExtensionsViewModel list, string php, PhpExtension extension, bool adding, bool confirming, bool canAdd) +{ + public string Name => extension.Name; + public string Glyph => !extension.Available ? Ui.GlyphWarning : extension.Declared ? Ui.GlyphPuzzle : Ui.GlyphInfo; + public string Tone => (!extension.Available ? Ui.Tone.Critical : extension.Declared ? Ui.Tone.Success : Ui.Tone.Caution).ToString(); + public string Detail => (extension.Declared ? $"Required by {string.Join(", ", extension.RequiredBy)}" : $"Not required, but used: {extension.Hint}") + + (extension.Available ? "" : $" — Flock's PHP {php} doesn't include it"); + public bool IsAdding => adding; + public bool IsConfirming => !adding && confirming; + public bool CanBeAdded => !adding && !confirming && !extension.Declared; + public bool CanAdd => canAdd; + public string RequireTooltip => $"Runs composer require ext-{extension.Name}:* in {list.SitePath}, which changes composer.json and composer.lock. Commit them with the project."; + + [RelayCommand] + private void Confirm() => list.Confirm(extension.Name); + + [RelayCommand] + private void Cancel() => list.Cancel(); + + [RelayCommand] + private void Require() => list.Require(extension.Name); +} diff --git a/windows/Flock.Tray/ViewModels/SiteSectionViewModels.cs b/windows/Flock.Tray/ViewModels/SiteSectionViewModels.cs new file mode 100644 index 00000000..425c4d5e --- /dev/null +++ b/windows/Flock.Tray/ViewModels/SiteSectionViewModels.cs @@ -0,0 +1,366 @@ +using CommunityToolkit.Mvvm.Input; +using Flock.Kit; +using Flock.Tray.Features.Sites; + +namespace Flock.Tray.ViewModels; + +/// +/// The Share section: the site at a temporary public URL through a Cloudflare quick tunnel, to show +/// work in progress. The daemon builds the assets when they need it, starts the tunnel and ends it +/// after 8 hours; the status follows it through building, starting, running or failed. +/// +internal sealed partial class ShareSectionViewModel(ISiteDetailHost host) : DetailViewModel +{ + private Status.Site site = new(); + private Status.SiteShare? share; + + public bool IsIdle => share is null; + public bool CanShare => site.Url is not null; + public bool IsStarting => share?.State is Status.ShareState.Building or Status.ShareState.Starting; + public string StartingText => share?.State == Status.ShareState.Building ? "Building assets…" : "Starting the tunnel…"; + public bool IsRunning => share is { State: Status.ShareState.Running, Url: not null }; + public bool IsStopped => share is not null && !IsStarting && !IsRunning; + + public string Url => share?.Url ?? ""; + public string Qr => IsRunning && share?.Qr is { Length: > 0 } data ? data : ""; + public bool HasQr => Qr.Length > 0; + public string Expires => share?.ExpiresAt is { } expires ? $"Ends at {expires.ToLocalTime():t}" : ""; + public string Allowed => share is { Allow.Count: > 0 } ? $"Only for: {string.Join(", ", share.Allow)}" : ""; + public bool IsDebug => share?.Debug == true; + public string Warning => share?.Warning ?? ""; + public bool HasWarning => share?.Warning is not null; + public string RebuildError => share?.RebuildError ?? ""; + public bool HasRebuildError => share?.RebuildError is not null; + public bool CanRebuild => share?.CanRebuild == true; + public string RebuildText => share?.Rebuilding == true ? "Rebuilding…" : "Rebuild assets"; + public bool IsRebuildEnabled => share?.Rebuilding != true; + public string Failure => share?.Error ?? "The share stopped."; + public string StopText => share?.IsRunning == true ? "Stop sharing" : share?.IsFailed == true ? "Dismiss" : "Stop"; + + /// The site the QR code is saved for. + public string SiteName => site.Name; + + public void Update(Status.Site value) + { + site = value; + share = value.Share; + Refresh(); + } + + [RelayCommand] + private void Start() + { + var current = site; + host.Start(current.Name, () => host.StartShareAsync(current)); + } + + [RelayCommand] + private void Stop() + { + var name = site.Name; + host.Start(name, () => host.Sites.StopShareAsync(name)); + } + + [RelayCommand] + private void Rebuild() + { + var name = site.Name; + host.Start(name, () => host.Sites.RebuildShareAsync(name)); + } + + [RelayCommand] + private void TryAgain() + { + var name = site.Name; + var allow = share?.Allow.ToList() ?? []; + var debug = share?.Debug == true; + host.Start(name, () => host.Sites.StartShareAsync(name, allow, debug)); + } + + [RelayCommand] + private void Open() + { + if (share?.Url is { } url) + { + host.OpenUrl(url); + } + } + + [RelayCommand] + private void Copy() + { + if (share?.Url is { } url) + { + host.Copy(url); + } + } + + [RelayCommand] + private void CopyQr() + { + if (Qr is { Length: > 0 } data) + { + _ = host.CopyQrAsync(data); + } + } + + [RelayCommand] + private void SaveQr() + { + if (Qr is { Length: > 0 } data) + { + _ = host.SaveQrAsync(data, site.Name); + } + } +} + +/// +/// What runs a site's PHP: php-cgi, or FrankenPHP (classic or Laravel Octane's worker mode), with +/// FrankenPHP's state, Restart and its log. +/// +internal sealed partial class SiteServerSectionViewModel(ISiteDetailHost host) : DetailViewModel +{ + private Status.Site site = new(); + private SiteRowViewModel? row; + private bool franken; + private bool octane; + private bool restarting; + private int serverShown; + private int modeShown; + + public int ServerIndex + { + get => serverShown; + set + { + if (Refreshing || value < 0) + { + return; + } + serverShown = value; + if (value == 1 == franken) + { + return; + } + var change = value == 1 ? Status.SiteServer.FrankenPhp : Status.SiteServer.FastCgi; + var (name, draft) = (site.Name, row); + host.Start(name, () => host.UpdateSiteAsync(name, new SiteChange(Server: change), () => draft?.SetDraftServer(change)), false); + } + } + + /// FrankenPHP needs PHP 8.2; a site already on it keeps the option (the daemon explains the fallback). + public bool CanPickFrankenPhp => franken || SupportsFrankenPhp(site.Php); + + public bool IsFrankenPhp => franken; + + public int ModeIndex + { + get => modeShown; + set + { + if (Refreshing || value < 0) + { + return; + } + modeShown = value; + if (value == 1 == octane) + { + return; + } + var worker = value == 1 ? Status.SiteServer.Octane : ""; + var (name, draft) = (site.Name, row); + host.Start(name, () => host.UpdateSiteAsync(name, new SiteChange(Worker: worker), () => draft?.SetDraftWorker(worker)), false); + } + } + + public bool CanPickOctane => octane || site.OctaneAvailable; + public string? ModeTooltip => site.OctaneAvailable ? null : "Install Laravel Octane in the project (composer require laravel/octane) to use worker mode."; + + // FrankenPHP's state: running, downloading, or why php-cgi serves the site meanwhile. + private Status.SiteFrankenPhp? State => franken ? site.FrankenPhp : null; + public bool IsRunning => State is { IsRunning: true }; + public string RunningText => State is { } state + ? $"Running FrankenPHP {state.Version} with PHP {state.PhpVersion}" + (site.Worker == Status.SiteServer.Octane ? " · worker mode" : "") + : ""; + public bool IsInstalling => State is { IsRunning: false, IsInstalling: true }; + public string InstallingText => State is { HasProgress: true } state + ? $"Downloading FrankenPHP for PHP {site.Php}… {Math.Round(state.Progress * 100)}%" + : $"Downloading FrankenPHP for PHP {site.Php}…"; + public double Progress => State is { HasProgress: true } state ? state.Progress : 0; + public bool IsProgressIndeterminate => State is not { HasProgress: true }; + public bool IsWaiting => State is { IsRunning: false, IsInstalling: false }; + public string Problem => IsWaiting ? State!.Problem : ""; + + public bool IsRestarting => restarting; + public bool CanRestart => site.FrankenPhp is { IsRunning: true } && !restarting; + + public string Note => franken && !site.OctaneAvailable + ? "FrankenPHP needs PHP 8.2 or newer. Requests that ask for Xdebug still go to PHP-CGI. For worker mode, install Laravel Octane in the project (composer require laravel/octane)." + : "FrankenPHP needs PHP 8.2 or newer. Requests that ask for Xdebug still go to PHP-CGI."; + + public void Update(Status.Site value, SiteRowViewModel? draft, bool isRestarting) + { + site = value; + row = draft; + restarting = isRestarting; + franken = (draft?.Server ?? value.Server) == Status.SiteServer.FrankenPhp; + octane = (draft?.Worker ?? value.Worker) == Status.SiteServer.Octane; + serverShown = franken ? 1 : 0; + modeShown = octane ? 1 : 0; + Refresh(); + } + + /// FrankenPHP runs PHP 8.2 and newer; an unknown version doesn't block it (the daemon checks too). + internal static bool SupportsFrankenPhp(string php) + { + var parts = php.Split('.'); + if (parts.Length < 2 || !int.TryParse(parts[0], out var major) || !int.TryParse(parts[1], out var minor)) + { + return true; + } + return major > 8 || major == 8 && minor >= 2; + } + + [RelayCommand] + private void Restart() + { + var name = site.Name; + host.Start(name, () => host.RestartAsync(name)); + } + + [RelayCommand] + private void ShowLog() => host.OpenLog(site.FrankenPhp is { Log.Length: > 0 } f ? f.Log : $"frankenphp-{site.Name}"); +} + +/// +/// A PHP site's Laravel Reverb server: turned on here, Flock runs php artisan reverb:start and +/// serves it at wss://<domain>/app. Shows its state, what the app's .env needs, Restart and +/// its output. +/// +internal sealed partial class ReverbSectionViewModel(ISiteDetailHost host, OutputViewModel output) : DetailViewModel +{ + private Status.Site site = new(); + private Status.SiteReverb? reverb; + private string scheme = "ws"; + private bool restarting; + private bool shown; + + public OutputViewModel OutputPanel { get; } = output; + + public bool IsOn + { + get => shown; + set + { + if (Refreshing || value == shown) + { + return; + } + // Shown again afterwards, so a refused change snaps back. + shown = value; + var name = site.Name; + host.Start(name, () => host.UpdateSiteAsync(name, new SiteChange(Reverb: value), null)); + } + } + + public bool CanToggle => site.ReverbAvailable || reverb is not null; + public bool IsOff => reverb is null; + public string OffNote => site.ReverbAvailable + ? $"Flock runs php artisan reverb:start for the site. Browsers reach it at {scheme}://{site.Domain}/app, with no port or certificate of its own." + : "Install Laravel Reverb in the project (php artisan install:broadcasting --reverb) to run it."; + public bool HasReverb => reverb is not null; + public bool IsRunning => reverb?.State == Status.ReverbState.Running; + public string RunningText => $"Running at {scheme}://{site.Domain}/app"; + public bool IsFailed => reverb?.State == Status.ReverbState.Failed; + public string FailedText => reverb is { Problem.Length: > 0 } ? reverb.Problem : "Failed"; + public bool IsUnavailable => reverb is not null && !IsRunning && !IsFailed; + public string UnavailableText => reverb is { Problem.Length: > 0 } ? reverb.Problem : "Reverb can't run."; + public bool HasEnv => reverb is { Env.Count: > 0 }; + public string Env => reverb is null ? "" : string.Join("\n", reverb.Env); + public bool IsRestarting => restarting; + public bool CanRestart => reverb?.IsRunning == true && !restarting; + public bool HasOutput => Output is not null; + + /// Reverb's output, once it has a log. + public OutputSource? Output => reverb is { Log.Length: > 0 } ? new OutputSource(reverb.Log, reverb.IsRunning) : null; + + public void Update(Status.Site value, SiteRowViewModel? row, bool isRestarting) + { + site = value; + reverb = value.Reverb; + scheme = (row?.Secured ?? value.Secured) ? "wss" : "ws"; + restarting = isRestarting; + shown = reverb is not null; + Refresh(); + } + + [RelayCommand] + private void Restart() + { + var name = site.Name; + host.Start(name, () => host.RestartReverbAsync(name)); + } + + [RelayCommand] + private void CopyEnv() => host.Copy(Env); +} + +/// +/// A Node.js site's dev server (started on the site's first visit, asleep after 30 idle minutes): +/// its state, Start, Stop and Restart, the command and its output. +/// +internal sealed partial class DevServerSectionViewModel(ISiteDetailHost host, OutputViewModel output) : DetailViewModel +{ + private Status.Site site = new(); + private Status.SiteNodeServer? server; + + public OutputViewModel OutputPanel { get; } = output; + + public bool HasServer => server is not null; + public bool HasNoServer => server is null; + public string Tone => ToneName(server?.State switch + { + Status.NodeServerState.Running => Ui.Tone.Success, + Status.NodeServerState.Starting => Ui.Tone.Caution, + Status.NodeServerState.Failed => Ui.Tone.Critical, + _ => Ui.Tone.Neutral, + }); + public string StateLabel => server?.StateLabel ?? ""; + /// What's wrong, unless the state already says it. + public string Problem => server is { State: not Status.NodeServerState.Failed } ? server.Problem : ""; + public string Command => server?.Command ?? ""; + public bool CanStart => server?.CanStart == true; + public bool CanStop => server?.CanStop == true; + public bool CanRestart => server?.CanRestart == true; + + public OutputSource? Output => server is null ? null + : new OutputSource(server.Log, server.State is Status.NodeServerState.Starting or Status.NodeServerState.Running); + + public void Update(Status.Site value) + { + site = value; + server = value.IsNode ? value.NodeServer : null; + Refresh(); + } + + [RelayCommand] + private void StartServer() + { + var name = site.Name; + host.Start(name, () => host.Sites.RestartAsync(name)); + } + + [RelayCommand] + private void StopServer() + { + var name = site.Name; + host.Start(name, () => host.Sites.StopSiteAsync(name)); + } + + [RelayCommand] + private void RestartServer() + { + var name = site.Name; + host.Start(name, () => host.RestartAsync(name)); + } +} diff --git a/windows/Flock.Tray/ViewModels/SitesViewModel.cs b/windows/Flock.Tray/ViewModels/SitesViewModel.cs index b47fd559..8b547434 100644 --- a/windows/Flock.Tray/ViewModels/SitesViewModel.cs +++ b/windows/Flock.Tray/ViewModels/SitesViewModel.cs @@ -80,7 +80,9 @@ public bool Apply(Status? status) OnPropertyChanged(nameof(Error)); OnPropertyChanged(nameof(HasSites)); OnPropertyChanged(nameof(HasProxies)); - return !ReferenceEquals(selectedBefore, Selected) || !ReferenceEquals(proxyBefore, SelectedProxy) || selectedChanged + return !ReferenceEquals(selectedBefore, Selected) + || !ReferenceEquals(proxyBefore, SelectedProxy) + || selectedChanged || !worktreesBefore.SequenceEqual(WorktreeNames(selectedBefore)); } @@ -152,17 +154,28 @@ internal bool Apply(Status.Proxy value) var previous = Snapshot; Snapshot = value; OnPropertyChanged(string.Empty); - return previous.Name != value.Name || previous.Domain != value.Domain || previous.Upstream != value.Upstream - || previous.Secured != value.Secured || previous.Url != value.Url || previous.Problem != value.Problem - || previous.Running != value.Running || previous.Server != value.Server + return previous.Name != value.Name + || previous.Domain != value.Domain + || previous.Upstream != value.Upstream + || previous.Secured != value.Secured + || previous.Url != value.Url + || previous.Problem != value.Problem + || previous.Running != value.Running + || previous.Server != value.Server || !CommandEqual(previous.Command, value.Command); } private static bool CommandEqual(Status.ProxyCommand? previous, Status.ProxyCommand? current) { - return previous is null && current is null || previous is not null && current is not null && - previous.Command == current.Command && previous.Dir == current.Dir && previous.StopCommand == current.StopCommand && - previous.State == current.State && previous.Problem == current.Problem && previous.Log == current.Log; + return previous is null && current is null + || previous is not null + && current is not null + && previous.Command == current.Command + && previous.Dir == current.Dir + && previous.StopCommand == current.StopCommand + && previous.State == current.State + && previous.Problem == current.Problem + && previous.Log == current.Log; } } @@ -313,7 +326,8 @@ internal bool Apply(Status.Site value) ?? (site.Worktree is { IsFailed: true, Error.Length: > 0 } setup ? setup.Error : null) ?? site.Notice ?? site.Domains.Select(domain => domain.Problem is { } problem ? $"{domain.Host}: {problem}" : null).FirstOrDefault(problem => problem is not null) - ?? (site.IsFrankenPhp && site.FrankenPhp is { IsRunning: false, IsInstalling: false, Problem.Length: > 0 } frankenPhp ? frankenPhp.Problem : null); + ?? (site.IsFrankenPhp && site.FrankenPhp is { IsRunning: false, IsInstalling: false, Problem.Length: > 0 } frankenPhp ? frankenPhp.Problem : null) + ?? (site.Reverb is { State: Status.ReverbState.Failed or Status.ReverbState.Unavailable, Problem.Length: > 0 } reverb ? reverb.Problem : null); } private static bool RenderedFieldsChanged(Status.Site previous, Status.Site current) @@ -346,6 +360,8 @@ private static bool RenderedFieldsChanged(Status.Site previous, Status.Site curr || previous.Worker != current.Worker || previous.OctaneAvailable != current.OctaneAvailable || !FrankenPhpEqual(previous.FrankenPhp, current.FrankenPhp) + || previous.ReverbAvailable != current.ReverbAvailable + || !ReverbEqual(previous.Reverb, current.Reverb) || previous.Boost != current.Boost || !ShareEqual(previous.Share, current.Share) || previous.Parent != current.Parent @@ -357,40 +373,85 @@ private static bool RenderedFieldsChanged(Status.Site previous, Status.Site curr private static bool NodeServerEqual(Status.SiteNodeServer? previous, Status.SiteNodeServer? current) { - return previous is null && current is null || previous is not null && current is not null && - previous.State == current.State && previous.Port == current.Port && previous.Command == current.Command && - previous.Problem == current.Problem && previous.Log == current.Log; + return previous is null && current is null + || previous is not null + && current is not null + && previous.State == current.State + && previous.Port == current.Port + && previous.Command == current.Command + && previous.Problem == current.Problem + && previous.Log == current.Log; } private static bool WorktreeEqual(Status.WorktreeSetup? previous, Status.WorktreeSetup? current) { - return previous is null && current is null || previous is not null && current is not null && - previous.State == current.State && previous.Step == current.Step && previous.Error == current.Error && - previous.Log == current.Log && previous.DbMode == current.DbMode && previous.Database == current.Database; + return previous is null && current is null + || previous is not null + && current is not null + && previous.State == current.State + && previous.Step == current.Step + && previous.Error == current.Error + && previous.Log == current.Log + && previous.DbMode == current.DbMode + && previous.Database == current.Database; } private static bool DomainsEqual(IReadOnlyList previous, IReadOnlyList current) { - return previous.Count == current.Count && previous.Zip(current).All(pair => - pair.First.Entry == pair.Second.Entry && pair.First.Host == pair.Second.Host && pair.First.Alias == pair.Second.Alias && - pair.First.Url == pair.Second.Url && pair.First.Problem == pair.Second.Problem); + return previous.Count == current.Count + && previous.Zip(current).All(pair => pair.First.Entry == pair.Second.Entry + && pair.First.Host == pair.Second.Host + && pair.First.Alias == pair.Second.Alias + && pair.First.Url == pair.Second.Url + && pair.First.Problem == pair.Second.Problem); } private static bool FrankenPhpEqual(Status.SiteFrankenPhp? previous, Status.SiteFrankenPhp? current) { - return previous is null && current is null || previous is not null && current is not null && - previous.State == current.State && previous.Version == current.Version && previous.PhpVersion == current.PhpVersion && - previous.Port == current.Port && previous.Progress == current.Progress && previous.Problem == current.Problem && previous.Log == current.Log; + return previous is null && current is null + || previous is not null + && current is not null + && previous.State == current.State + && previous.Version == current.Version + && previous.PhpVersion == current.PhpVersion + && previous.Port == current.Port + && previous.Progress == current.Progress + && previous.Problem == current.Problem + && previous.Log == current.Log; + } + + private static bool ReverbEqual(Status.SiteReverb? previous, Status.SiteReverb? current) + { + return previous is null && current is null + || previous is not null + && current is not null + && previous.State == current.State + && previous.Port == current.Port + && previous.Env.SequenceEqual(current.Env) + && previous.Problem == current.Problem + && previous.Log == current.Log; } private static bool ShareEqual(Status.SiteShare? previous, Status.SiteShare? current) { - return previous is null && current is null || previous is not null && current is not null && - previous.Site == current.Site && previous.State == current.State && previous.Url == current.Url && - previous.Allow.SequenceEqual(current.Allow) && previous.Debug == current.Debug && previous.StartedAt == current.StartedAt && - previous.ExpiresAt == current.ExpiresAt && previous.Built == current.Built && previous.CanRebuild == current.CanRebuild && - previous.Rebuilding == current.Rebuilding && previous.RebuildError == current.RebuildError && previous.Error == current.Error && - previous.Warning == current.Warning && previous.Log == current.Log && previous.Qr == current.Qr; + return previous is null && current is null + || previous is not null + && current is not null + && previous.Site == current.Site + && previous.State == current.State + && previous.Url == current.Url + && previous.Allow.SequenceEqual(current.Allow) + && previous.Debug == current.Debug + && previous.StartedAt == current.StartedAt + && previous.ExpiresAt == current.ExpiresAt + && previous.Built == current.Built + && previous.CanRebuild == current.CanRebuild + && previous.Rebuilding == current.Rebuilding + && previous.RebuildError == current.RebuildError + && previous.Error == current.Error + && previous.Warning == current.Warning + && previous.Log == current.Log + && previous.Qr == current.Qr; } private void SetDraft(ref T? field, T value, string property) where T : struct diff --git a/windows/Flock.Tray/ViewModels/WorktreeDetailViewModel.cs b/windows/Flock.Tray/ViewModels/WorktreeDetailViewModel.cs new file mode 100644 index 00000000..929820f4 --- /dev/null +++ b/windows/Flock.Tray/ViewModels/WorktreeDetailViewModel.cs @@ -0,0 +1,162 @@ +using CommunityToolkit.Mvvm.Input; +using Flock.Kit; + +namespace Flock.Tray.ViewModels; + +/// +/// The selected worktree: its branch, folder and address, its site's settings (read-only, they are +/// changed on the site), a Node.js worktree's dev server, its database and its setup. +/// +internal sealed partial class WorktreeDetailViewModel : DetailViewModel +{ + // The database pickers' modes and their labels, in their order. + internal static readonly string[] DatabaseModes = [Status.WorktreeDatabase.Shared, Status.WorktreeDatabase.Copy, Status.WorktreeDatabase.Fresh]; + internal static readonly string[] DatabaseChoices = ["The site's", "A copy of the site's", "A new, empty one"]; + + private readonly ISiteDetailHost host; + private Status.Site worktree = new(); + private bool hasParent; + private int databaseIndex; + private int databaseShown; + + public WorktreeDetailViewModel(ISiteDetailHost host, OutputViewModel output) + { + this.host = host; + DevServer = new DevServerSectionViewModel(host, output); + } + + public bool IsShown { get; private set; } + + // Header: branch, address and folder, and the worktree's actions. + public string Label => worktree.WorktreeLabel; + public string Url => worktree.Url ?? ""; + public bool HasUrl => worktree.Url is not null; + public string Path => worktree.Path; + public string OpenLabel => $"Open {worktree.Url ?? worktree.Domain}"; + public string Problem => worktree.Problem ?? ""; + + // Worktree: its branch, folder and site. + public string Branch => worktree.Branch.Length > 0 ? worktree.Branch : "None (detached HEAD)"; + public string Domain => worktree.Domain; + public string Parent => worktree.Parent ?? ""; + public bool HasParent => hasParent; + public bool HasNoParent => !hasParent; + + // Settings: the site's, so they are changed there. + public bool IsPhp => !worktree.IsNode; + public bool IsNode => worktree.IsNode; + public string PhpText => $"PHP {worktree.Php}"; + public string NodeText => worktree.Node is { Length: > 0 } node ? $"Node {node}" : "None"; + public string HttpsText => worktree.Secured ? "On" : "Off"; + public string SettingsNote => worktree.IsNode + ? $"Uses {Parent}'s settings: Node.js and HTTPS (a version the branch pins applies too). Change them on {Parent}." + : $"Uses {Parent}'s settings: PHP, Node.js, HTTPS and the document root (versions the branch pins apply too). Change them on {Parent}."; + + public DevServerSectionViewModel DevServer { get; } + + // Database: the site's or its own, and a picker that switches it. + private Status.WorktreeSetup? Setup => worktree.Worktree; + public string CurrentDatabase => Setup is { HasOwnDatabase: true } setup ? setup.Database : "The site's"; + public IReadOnlyList DatabaseItems { get; } = DatabaseChoices; + + public int DatabaseIndex + { + get => databaseShown; + set + { + if (Refreshing || value < 0 || value == databaseIndex) + { + return; + } + var mode = DatabaseModes[value]; + // Shown again once the daemon has switched it; until then, and when cancelled, the current one. + databaseShown = databaseIndex; + host.Post(() => RefreshOne(nameof(DatabaseIndex))); + var current = worktree; + host.Start(current.Name, () => host.SwitchDatabaseAsync(current, mode)); + } + } + + public bool CanSwitchDatabase => Setup is { IsBusy: false }; + public string? DatabaseTooltip => Setup is null ? "Set the worktree up first." : null; + + // Setup: queued, running (the step), set up or failed (why), with its log and Run Setup Again. + public bool IsNotSetUp => Setup is null; + public bool IsSetUp => Setup is not null; + public bool IsBusy => Setup is { IsBusy: true }; + public string BusyText => Setup switch + { + { State: Status.WorktreeState.Queued } => "Waiting for another setup to finish…", + { Step.Length: > 0 } setup => setup.Step + "…", + _ => "Setting up…", + }; + public bool IsFailed => Setup is { IsBusy: false, IsFailed: true }; + public string FailedText => Setup is { Error.Length: > 0 } setup ? setup.Error : "The setup failed."; + public bool IsReady => Setup is { IsBusy: false, IsFailed: false }; + public bool HasLog => Setup is { Log.Length: > 0 }; + public bool CanRunAgain => Setup is { IsBusy: false }; + + public OutputSource? Output => worktree.IsNode ? DevServer.Output : null; + + public void Hide() + { + if (IsShown) + { + IsShown = false; + OnPropertyChanged(nameof(IsShown)); + } + } + + public void Update(Status.Site value, Status? status) + { + worktree = value; + hasParent = status?.Sites.Any(s => s.Name == value.Parent) == true; + IsShown = true; + // A mode left to flock.yml is unknown here, unless the worktree uses the site's database. + databaseIndex = Array.IndexOf(DatabaseModes, value.Worktree?.DbMode ?? ""); + if (databaseIndex < 0 && value.Worktree is not { HasOwnDatabase: true }) + { + databaseIndex = 0; + } + databaseShown = databaseIndex; + DevServer.Update(value); + Refresh(); + } + + [RelayCommand] + private void Open() => host.Open(worktree); + + [RelayCommand] + private void Reveal() => host.Reveal(worktree.Path); + + [RelayCommand] + private void Remove() + { + var current = worktree; + host.Start(current.Name, () => host.RemoveWorktreeAsync(current)); + } + + [RelayCommand] + private void OpenSite() => host.Select(Parent); + + [RelayCommand] + private void SetUp() + { + var current = worktree; + host.Start(current.Name, () => host.SetUpWorktreeAsync(current, false)); + } + + [RelayCommand] + private void RunSetupAgain() + { + var current = worktree; + host.Start(current.Name, () => host.SetUpWorktreeAsync(current, true)); + } + + [RelayCommand] + private void ShowLog() + { + var current = worktree; + host.Start("log:" + current.Name, () => host.ShowSetupLogAsync(current)); + } +}