From fb4e657844036ffc0bbd31799aebdd3f1f3b70e7 Mon Sep 17 00:00:00 2001 From: Sinan Eldem Date: Mon, 19 Oct 2026 02:44:30 +0300 Subject: [PATCH 01/26] fix(certs): read the system CA bundle where Linux distributions keep it --- internal/certs/system_unix.go | 23 ++++++++++++++++-- internal/certs/system_unix_test.go | 38 ++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 2 deletions(-) create mode 100644 internal/certs/system_unix_test.go diff --git a/internal/certs/system_unix.go b/internal/certs/system_unix.go index 7cadc223..ce28edf1 100644 --- a/internal/certs/system_unix.go +++ b/internal/certs/system_unix.go @@ -4,8 +4,27 @@ package certs import "os" +// bundleFiles are where distributions keep the trust store as one PEM file (the list Go's +// crypto/x509 reads), tried in order. SSL_CERT_FILE, when set, comes first. +var bundleFiles = []string{ + "/etc/ssl/certs/ca-certificates.crt", // Debian, Ubuntu, Arch, Gentoo + "/etc/pki/tls/certs/ca-bundle.crt", // Fedora, RHEL + "/etc/ssl/ca-bundle.pem", // openSUSE + "/etc/pki/tls/cacert.pem", // OpenELEC + "/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem", // CentOS, RHEL 7 + "/etc/ssl/cert.pem", // Alpine, the BSDs +} + // SystemRoots returns the OS trust store as PEM, for tools (PHP) that cannot read it themselves. func SystemRoots() []byte { - data, _ := os.ReadFile("/etc/ssl/cert.pem") - return data + files := bundleFiles + if env := os.Getenv("SSL_CERT_FILE"); env != "" { + files = append([]string{env}, files...) + } + for _, f := range files { + if data, err := os.ReadFile(f); err == nil && len(data) > 0 { + return data + } + } + return nil } diff --git a/internal/certs/system_unix_test.go b/internal/certs/system_unix_test.go new file mode 100644 index 00000000..9d4a4a78 --- /dev/null +++ b/internal/certs/system_unix_test.go @@ -0,0 +1,38 @@ +//go:build !windows && !darwin + +package certs + +import ( + "os" + "path/filepath" + "testing" +) + +func TestSystemRootsReadsTheFirstBundleThatExists(t *testing.T) { + dir := t.TempDir() + missing, empty, debian, alpine := filepath.Join(dir, "missing"), filepath.Join(dir, "empty"), filepath.Join(dir, "debian"), filepath.Join(dir, "alpine") + for path, data := range map[string]string{empty: "", debian: "debian roots", alpine: "alpine roots"} { + if err := os.WriteFile(path, []byte(data), 0o644); err != nil { + t.Fatal(err) + } + } + old := bundleFiles + bundleFiles = []string{missing, empty, debian, alpine} + t.Cleanup(func() { bundleFiles = old }) + t.Setenv("SSL_CERT_FILE", "") + + if got := string(SystemRoots()); got != "debian roots" { + t.Errorf("SystemRoots() = %q, want the Debian bundle", got) + } + + t.Setenv("SSL_CERT_FILE", alpine) + if got := string(SystemRoots()); got != "alpine roots" { + t.Errorf("with SSL_CERT_FILE: %q, want its bundle", got) + } + + bundleFiles = []string{missing} + t.Setenv("SSL_CERT_FILE", "") + if got := SystemRoots(); got != nil { + t.Errorf("without a bundle: %q, want nil", got) + } +} From 18df29e82b41588a4bf176153790eff789562d4c Mon Sep 17 00:00:00 2001 From: Sinan Eldem Date: Mon, 19 Oct 2026 02:44:46 +0300 Subject: [PATCH 02/26] feat(binaries): name linux-x64 and linux-arm64 in the manifest --- internal/binaries/binaries.go | 8 ++++++-- internal/binaries/binaries_test.go | 2 +- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/internal/binaries/binaries.go b/internal/binaries/binaries.go index 26008695..0c704aa8 100644 --- a/internal/binaries/binaries.go +++ b/internal/binaries/binaries.go @@ -130,8 +130,8 @@ func (m *Manifest) Versions(pkg, platform string) []Version { return out } -// Platform names this machine in the manifest ("macos-arm64", "windows-x64"); "" where Flock -// publishes no packages (Flock supports Apple Silicon Macs and Windows x64). +// Platform names this machine in the manifest ("macos-arm64", "windows-x64", "linux-x64", +// "linux-arm64"); "" where Flock publishes no packages. func Platform() string { return platform(runtime.GOOS, runtime.GOARCH) } @@ -142,6 +142,10 @@ func platform(goos, goarch string) string { return "macos-arm64" case goos == "windows" && goarch == "amd64": return "windows-x64" + case goos == "linux" && goarch == "amd64": + return "linux-x64" + case goos == "linux" && goarch == "arm64": + return "linux-arm64" } return "" } diff --git a/internal/binaries/binaries_test.go b/internal/binaries/binaries_test.go index e9a61b9e..36d01390 100644 --- a/internal/binaries/binaries_test.go +++ b/internal/binaries/binaries_test.go @@ -230,7 +230,7 @@ func TestEmbeddedKeyAndPlatforms(t *testing.T) { if len(PublicKey()) != ed25519.PublicKeySize { t.Fatal("embedded key") } - for _, c := range [][3]string{{"darwin", "arm64", "macos-arm64"}, {"darwin", "amd64", ""}, {"windows", "amd64", "windows-x64"}, {"linux", "amd64", ""}} { + for _, c := range [][3]string{{"darwin", "arm64", "macos-arm64"}, {"darwin", "amd64", ""}, {"windows", "amd64", "windows-x64"}, {"linux", "amd64", "linux-x64"}, {"linux", "arm64", "linux-arm64"}, {"linux", "386", ""}} { if got := platform(c[0], c[1]); got != c[2] { t.Errorf("%s/%s: %q, want %q", c[0], c[1], got, c[2]) } From d06e02b4fe386d2e26805ff68a5615d8b7737b1e Mon Sep 17 00:00:00 2001 From: Sinan Eldem Date: Mon, 19 Oct 2026 02:46:27 +0300 Subject: [PATCH 03/26] feat(daemon): check on Linux that .test lookups reach Flock's DNS server --- internal/daemon/wildcard.go | 22 ++++++++++---- internal/daemon/wildcard_test.go | 12 ++++++-- internal/dns/resolved.go | 50 ++++++++++++++++++++++++++++++++ internal/dns/resolved_test.go | 39 +++++++++++++++++++++++++ 4 files changed, 115 insertions(+), 8 deletions(-) create mode 100644 internal/dns/resolved.go create mode 100644 internal/dns/resolved_test.go diff --git a/internal/daemon/wildcard.go b/internal/daemon/wildcard.go index faaba336..3930ea9f 100644 --- a/internal/daemon/wildcard.go +++ b/internal/daemon/wildcard.go @@ -37,8 +37,10 @@ func (d *daemon) recheckWildcard() { // checkWildcard looks up a random name under the TLD through the system resolver, the way browsers // and PHP do, and checks that the query reached Flock's DNS server. On Windows that only happens -// when the NRPT rule from `flock install` is in effect, on macOS when /etc/resolver/ is; -// Group Policy, VPN clients and security software can override either without Flock noticing otherwise. +// when the NRPT rule from `flock install` is in effect, on macOS when /etc/resolver/ is, on +// Linux when systemd-resolved has the drop-in from `flock install` (or another resolver forwards +// the TLD); Group Policy, VPN clients and security software can override them without Flock +// noticing otherwise. func (d *daemon) checkWildcard() { if !d.checking.CompareAndSwap(false, true) { return @@ -64,12 +66,12 @@ func (d *daemon) checkWildcard() { state.problem = fmt.Sprintf("macOS doesn't send .%s lookups to Flock (a VPN or DNS filter may be overriding /etc/resolver)", cfg.TLD) } state.ok = state.problem == "" - case runtime.GOOS != "windows": - state.ok = true // nothing to check elsewhere (Flock runs on macOS and Windows) case reaches(srv, cfg.TLD): state.ok = true - default: + case runtime.GOOS == "windows": state.problem = windowsWildcardProblem(cfg.TLD) + default: + state.problem = linuxWildcardProblem(cfg.TLD, cfg.Ports.DNS) } d.mu.Lock() @@ -93,7 +95,8 @@ func lookupReaches(srv *dns.Server, tld string) bool { ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() // PreferGo false: ask the system resolver (which applies NRPT rules on Windows, /etc/resolver - // on macOS), as browsers and PHP do. On macOS that is libSystem's, with or without cgo. + // on macOS, systemd-resolved's routing domains on Linux), as browsers and PHP do. On macOS that + // is libSystem's, with or without cgo. addrs, err := (&net.Resolver{PreferGo: false}).LookupHost(ctx, name) return seen() && err == nil && slices.Contains(addrs, "127.0.0.1") } @@ -108,3 +111,10 @@ func windowsWildcardProblem(tld string) string { return fmt.Sprintf("Windows doesn't send .%s lookups to Flock (a VPN or security software may be overriding DNS)", tld) } } + +func linuxWildcardProblem(tld string, port int) string { + if problem := dns.ResolvedProblem(tld, port); problem != "" { + return problem + } + return fmt.Sprintf("Linux doesn't send .%s lookups to Flock (a VPN or DNS filter may be overriding systemd-resolved, or /etc/resolv.conf doesn't use it)", tld) +} diff --git a/internal/daemon/wildcard_test.go b/internal/daemon/wildcard_test.go index e8091d7e..6eb7ae9c 100644 --- a/internal/daemon/wildcard_test.go +++ b/internal/daemon/wildcard_test.go @@ -25,12 +25,14 @@ func init() { windowsDNSPort = ln.LocalAddr().(*net.UDPAddr).Port _ = ln.Close() - // The real /etc/resolver points at the real daemon, never at a test daemon's port. + // The real /etc/resolver and systemd-resolved drop-in point at the real daemon, never at a + // test daemon's port. dir, err := os.MkdirTemp("", "flock-resolver-") if err != nil { panic(err) } dns.ResolverDir = dir + dns.ResolvedDir = dir } func waitStatus(t *testing.T, client *api.Client, ok func(api.Status) bool) api.Status { @@ -110,11 +112,17 @@ func TestWildcardCheckReportsWhyItDoesNotWork(t *testing.T) { client := startDaemon(t, root) st := waitStatus(t, client, func(st api.Status) bool { return st.DNS.Wildcard || st.DNS.WildcardProblem != "" }) - if runtime.GOOS == "windows" { + if runtime.GOOS != "darwin" { // Lookups never reach the test daemon's DNS server. if st.DNS.Wildcard || st.DNS.WildcardProblem == "" { t.Fatalf("want a problem, got %+v", st.DNS) } + if runtime.GOOS == "windows" { + return + } + // Linux: they do once systemd-resolved (or another resolver) sends them to Flock. + arrives.Store(true) + reloadUntil(t, client, func(st api.Status) bool { return st.DNS.Wildcard && st.DNS.WildcardProblem == "" }) return } diff --git a/internal/dns/resolved.go b/internal/dns/resolved.go new file mode 100644 index 00000000..6219496f --- /dev/null +++ b/internal/dns/resolved.go @@ -0,0 +1,50 @@ +package dns + +import ( + "errors" + "fmt" + "os" + "path/filepath" +) + +// ResolvedDir is where systemd-resolved reads drop-in configuration (resolved.conf(5)). Tests move it. +var ResolvedDir = "/etc/systemd/resolved.conf.d" + +// ResolvedRuntimeDir exists while systemd-resolved runs. Tests move it. +var ResolvedRuntimeDir = "/run/systemd/resolve" + +// ResolvedFile is the drop-in `flock install` writes on Linux to send tld to Flock. +func ResolvedFile(tld string) string { return filepath.Join(ResolvedDir, "flock-"+tld+".conf") } + +// ResolvedContent is what ResolvedFile holds when it sends tld lookups to Flock's DNS server on +// port. ~tld routes those lookups to Flock alone. A global DNS server also gets asked for every +// other name, which Flock refuses and the network's DNS servers answer. +func ResolvedContent(tld string, port int) string { + return fmt.Sprintf("# Managed by Flock\n[Resolve]\nDNS=127.0.0.1:%d\nDomains=~%s\n", port, tld) +} + +// ResolvedRunning reports whether systemd-resolved runs on this machine. +func ResolvedRunning() bool { + _, err := os.Stat(ResolvedRuntimeDir) + return err == nil +} + +// ResolvedProblem explains why systemd-resolved does not send tld lookups to Flock's DNS server on +// port, or returns "" when its drop-in does. +func ResolvedProblem(tld string, port int) string { + if !ResolvedRunning() { + return fmt.Sprintf("systemd-resolved isn't running, so Linux doesn't send .%s lookups to Flock — "+ + "have your DNS resolver (dnsmasq, NetworkManager, ...) forward .%s to 127.0.0.1 port %d", tld, tld, port) + } + path := ResolvedFile(tld) + data, err := os.ReadFile(path) + switch { + case errors.Is(err, os.ErrNotExist): + return fmt.Sprintf("%s is missing, so systemd-resolved doesn't send .%s lookups to Flock — run `flock install`", path, tld) + case err != nil: + return fmt.Sprintf("can't read %s (%v) — run `flock install`", path, err) + case string(data) != ResolvedContent(tld, port): + return fmt.Sprintf("%s doesn't point at Flock's DNS server on port %d — run `flock install`", path, port) + } + return "" +} diff --git a/internal/dns/resolved_test.go b/internal/dns/resolved_test.go new file mode 100644 index 00000000..d59e0174 --- /dev/null +++ b/internal/dns/resolved_test.go @@ -0,0 +1,39 @@ +package dns + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestResolvedProblem(t *testing.T) { + oldDir, oldRuntime := ResolvedDir, ResolvedRuntimeDir + ResolvedDir, ResolvedRuntimeDir = t.TempDir(), filepath.Join(t.TempDir(), "missing") + t.Cleanup(func() { ResolvedDir, ResolvedRuntimeDir = oldDir, oldRuntime }) + + if p := ResolvedProblem("test", 53535); !strings.Contains(p, "systemd-resolved isn't running") || !strings.Contains(p, "port 53535") { + t.Fatalf("without systemd-resolved: %q", p) + } + + ResolvedRuntimeDir = t.TempDir() + if p := ResolvedProblem("test", 53535); !strings.Contains(p, "missing") || !strings.Contains(p, "flock install") { + t.Fatalf("missing drop-in: %q", p) + } + + if err := os.WriteFile(ResolvedFile("test"), []byte(ResolvedContent("test", 53535)), 0o644); err != nil { + t.Fatal(err) + } + if p := ResolvedProblem("test", 53535); p != "" { + t.Fatalf("current drop-in: %q", p) + } + if p := ResolvedProblem("test", 5300); !strings.Contains(p, "port 5300") || !strings.Contains(p, "flock install") { + t.Fatalf("other port: %q", p) + } + if got := ResolvedFile("test"); got != filepath.Join(ResolvedDir, "flock-test.conf") { + t.Fatalf("file: %q", got) + } + if got := ResolvedContent("test", 53535); got != "# Managed by Flock\n[Resolve]\nDNS=127.0.0.1:53535\nDomains=~test\n" { + t.Fatalf("content changed (must match what flock install writes): %q", got) + } +} From 8d4291e9177646d725283d8eb4c1350b86296dbf Mon Sep 17 00:00:00 2001 From: Sinan Eldem Date: Mon, 19 Oct 2026 02:47:03 +0300 Subject: [PATCH 04/26] fix(ports): a low port without permission isn't a taken one on Linux --- internal/ports/ports.go | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/internal/ports/ports.go b/internal/ports/ports.go index 51466730..df2dfc56 100644 --- a/internal/ports/ports.go +++ b/internal/ports/ports.go @@ -2,20 +2,24 @@ package ports import ( + "errors" "fmt" "net" "runtime" "slices" "strconv" "strings" + "syscall" "unicode" ) -// Free reports whether a TCP listener can be opened on host:port right now. +// Free reports whether a TCP listener can be opened on host:port right now. On Linux, permission +// denied counts as free: it is a port below 1024 without CAP_NET_BIND_SERVICE (which `flock +// install` gives the daemon), and says nothing about another program holding it. func Free(host string, port int) bool { ln, err := net.Listen("tcp", net.JoinHostPort(host, strconv.Itoa(port))) if err != nil { - return false + return runtime.GOOS == "linux" && errors.Is(err, syscall.EACCES) } _ = ln.Close() return true From ecd6f1cd9c680b7db04aaea091c6113b43345247 Mon Sep 17 00:00:00 2001 From: Sinan Eldem Date: Mon, 19 Oct 2026 02:52:09 +0300 Subject: [PATCH 05/26] refactor(cli): share the shims and shell profile code of macOS with Linux --- internal/cli/install_darwin.go | 105 +------------------------- internal/cli/install_darwin_test.go | 44 ----------- internal/cli/install_unix.go | 113 ++++++++++++++++++++++++++++ internal/cli/install_unix_test.go | 54 +++++++++++++ 4 files changed, 169 insertions(+), 147 deletions(-) create mode 100644 internal/cli/install_unix.go create mode 100644 internal/cli/install_unix_test.go diff --git a/internal/cli/install_darwin.go b/internal/cli/install_darwin.go index e2ed0662..a747cff6 100644 --- a/internal/cli/install_darwin.go +++ b/internal/cli/install_darwin.go @@ -6,7 +6,6 @@ import ( "os" "os/exec" "path/filepath" - "slices" "strconv" "strings" "time" @@ -20,115 +19,15 @@ import ( ) const ( - launchLabel = "dev.flock.daemon" - profileMarker = "# Flock (added by `flock install`)" + launchLabel = "dev.flock.daemon" // appBundleID and appExecutable identify the menu bar app (macos/build-app.sh). appBundleID = "dev.flock.bar" appExecutable = "FlockBar" ) -// shellProfiles are the profiles addToShellPath writes to (~/.zshrc always, ~/.bash_profile if present). +// shellProfiles are the profiles addToShellPath writes to: ~/.zshrc always, ~/.bash_profile if present. var shellProfiles = []string{".zshrc", ".bash_profile"} -func exeName(name string) string { return name } - -func writeShims(root, bin string) error { - dir := filepath.Join(root, "bin") - for _, tool := range tools { - script := fmt.Sprintf("#!/bin/sh\nexec %q exec %s \"$@\"\n", bin, tool) - if err := os.WriteFile(filepath.Join(dir, tool), []byte(script), 0o755); err != nil { - return err - } - } - return nil -} - -// addToShellPath prepends dirs, in this order, in ~/.zshrc (and ~/.bash_profile if present): a -// marked export line, appended once and rewritten when the directories change. -func addToShellPath(dirs ...string) error { - home, err := os.UserHomeDir() - if err != nil { - return err - } - - quoted := make([]string, len(dirs)) - for i, dir := range dirs { - quoted[i] = strconv.Quote(dir) - } - export := fmt.Sprintf("export PATH=%s:\"$PATH\"", strings.Join(quoted, ":")) - for _, name := range shellProfiles { - path := filepath.Join(home, name) - data, err := os.ReadFile(path) - if err != nil && name != ".zshrc" { - continue - } - lines := strings.Split(string(data), "\n") - if i := slices.Index(lines, profileMarker); i >= 0 { - if i+1 < len(lines) && lines[i+1] == export { - continue - } - if i+1 < len(lines) && strings.HasPrefix(lines[i+1], "export PATH=") { - lines[i+1] = export - } else { - lines = slices.Insert(lines, i+1, export) - } - if err := os.WriteFile(path, []byte(strings.Join(lines, "\n")), 0o644); err != nil { - return err - } - continue - } - f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) - if err != nil { - return err - } - _, err = f.WriteString("\n" + profileMarker + "\n" + export + "\n") - f.Close() - if err != nil { - return err - } - } - return nil -} - -// removeFromShellProfiles takes out what addToShellPath added: the marker, its export line and -// the blank line before them. It reports whether a profile had them. -func removeFromShellProfiles() (bool, error) { - home, err := os.UserHomeDir() - if err != nil { - return false, err - } - removed := false - for _, name := range shellProfiles { - path := filepath.Join(home, name) - data, err := os.ReadFile(path) - if err != nil { - continue - } - lines := strings.Split(string(data), "\n") - i := slices.Index(lines, profileMarker) - if i < 0 { - continue - } - end := i + 1 - if end < len(lines) && strings.HasPrefix(lines[end], "export PATH=") { - end++ - } - if i > 0 && lines[i-1] == "" { - i-- - } - lines = slices.Delete(lines, i, end) - info, err := os.Stat(path) - if err != nil { - return removed, err - } - if err := os.WriteFile(path, []byte(strings.Join(lines, "\n")), info.Mode().Perm()); err != nil { - return removed, err - } - removed = true - } - return removed, nil -} - func plistPath() (string, error) { home, err := os.UserHomeDir() if err != nil { diff --git a/internal/cli/install_darwin_test.go b/internal/cli/install_darwin_test.go index 1d6c89c7..785ec663 100644 --- a/internal/cli/install_darwin_test.go +++ b/internal/cli/install_darwin_test.go @@ -9,50 +9,6 @@ import ( "testing" ) -// Uninstalling leaves the shell profiles as they were before `flock install`. -func TestShellProfileRoundTrip(t *testing.T) { - home := t.TempDir() - t.Setenv("HOME", home) - - zshrc := filepath.Join(home, ".zshrc") - before := "export EDITOR=vim\nalias ll='ls -l'\n" - if err := os.WriteFile(zshrc, []byte(before), 0o600); err != nil { - t.Fatal(err) - } - - if err := addToShellPath("/f/bin", "/f/node-global/current/bin"); err != nil { - t.Fatal(err) - } - // A second install with other directories rewrites the line instead of adding another. - if err := addToShellPath("/f/bin"); err != nil { - t.Fatal(err) - } - data, _ := os.ReadFile(zshrc) - if strings.Count(string(data), profileMarker) != 1 || !strings.Contains(string(data), `export PATH="/f/bin":"$PATH"`) { - t.Fatalf("profile after install:\n%s", data) - } - - removed, err := removeFromShellProfiles() - if err != nil || !removed { - t.Fatalf("removed %v, err %v", removed, err) - } - data, _ = os.ReadFile(zshrc) - if string(data) != before { - t.Fatalf("profile after uninstall:\n%q\nwant\n%q", data, before) - } - if info, _ := os.Stat(zshrc); info.Mode().Perm() != 0o600 { - t.Fatalf("permissions changed to %v", info.Mode().Perm()) - } - // No ~/.bash_profile existed, and none is created. - if _, err := os.Stat(filepath.Join(home, ".bash_profile")); !os.IsNotExist(err) { - t.Fatalf(".bash_profile: %v", err) - } - - if removed, err := removeFromShellProfiles(); err != nil || removed { - t.Fatalf("second removal: removed %v, err %v", removed, err) - } -} - // The command line reaches the shell of `do shell script` intact, whatever the paths contain. func TestAdministratorScriptQuoting(t *testing.T) { exe := "/Users/o'brien/Library/Application Support/Flock/bin/flock" diff --git a/internal/cli/install_unix.go b/internal/cli/install_unix.go new file mode 100644 index 00000000..3d3bf1c0 --- /dev/null +++ b/internal/cli/install_unix.go @@ -0,0 +1,113 @@ +//go:build !windows + +package cli + +import ( + "fmt" + "os" + "path/filepath" + "slices" + "strconv" + "strings" +) + +const profileMarker = "# Flock (added by `flock install`)" + +func exeName(name string) string { return name } + +func writeShims(root, bin string) error { + dir := filepath.Join(root, "bin") + for _, tool := range tools { + script := fmt.Sprintf("#!/bin/sh\nexec %q exec %s \"$@\"\n", bin, tool) + if err := os.WriteFile(filepath.Join(dir, tool), []byte(script), 0o755); err != nil { + return err + } + } + return nil +} + +// addToShellPath prepends dirs, in this order, in the shell profiles (the first one always, the +// others if present): a marked export line, appended once and rewritten when the directories change. +func addToShellPath(dirs ...string) error { + home, err := os.UserHomeDir() + if err != nil { + return err + } + + quoted := make([]string, len(dirs)) + for i, dir := range dirs { + quoted[i] = strconv.Quote(dir) + } + export := fmt.Sprintf("export PATH=%s:\"$PATH\"", strings.Join(quoted, ":")) + for _, name := range shellProfiles { + path := filepath.Join(home, name) + data, err := os.ReadFile(path) + if err != nil && name != shellProfiles[0] { + continue + } + lines := strings.Split(string(data), "\n") + if i := slices.Index(lines, profileMarker); i >= 0 { + if i+1 < len(lines) && lines[i+1] == export { + continue + } + if i+1 < len(lines) && strings.HasPrefix(lines[i+1], "export PATH=") { + lines[i+1] = export + } else { + lines = slices.Insert(lines, i+1, export) + } + if err := os.WriteFile(path, []byte(strings.Join(lines, "\n")), 0o644); err != nil { + return err + } + continue + } + f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) + if err != nil { + return err + } + _, err = f.WriteString("\n" + profileMarker + "\n" + export + "\n") + f.Close() + if err != nil { + return err + } + } + return nil +} + +// removeFromShellProfiles takes out what addToShellPath added: the marker, its export line and +// the blank line before them. It reports whether a profile had them. +func removeFromShellProfiles() (bool, error) { + home, err := os.UserHomeDir() + if err != nil { + return false, err + } + removed := false + for _, name := range shellProfiles { + path := filepath.Join(home, name) + data, err := os.ReadFile(path) + if err != nil { + continue + } + lines := strings.Split(string(data), "\n") + i := slices.Index(lines, profileMarker) + if i < 0 { + continue + } + end := i + 1 + if end < len(lines) && strings.HasPrefix(lines[end], "export PATH=") { + end++ + } + if i > 0 && lines[i-1] == "" { + i-- + } + lines = slices.Delete(lines, i, end) + info, err := os.Stat(path) + if err != nil { + return removed, err + } + if err := os.WriteFile(path, []byte(strings.Join(lines, "\n")), info.Mode().Perm()); err != nil { + return removed, err + } + removed = true + } + return removed, nil +} diff --git a/internal/cli/install_unix_test.go b/internal/cli/install_unix_test.go new file mode 100644 index 00000000..6eed4b89 --- /dev/null +++ b/internal/cli/install_unix_test.go @@ -0,0 +1,54 @@ +//go:build !windows + +package cli + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// Uninstalling leaves the shell profiles as they were before `flock install`. +func TestShellProfileRoundTrip(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + profile, optional := filepath.Join(home, shellProfiles[0]), filepath.Join(home, shellProfiles[1]) + before := "export EDITOR=vim\nalias ll='ls -l'\n" + if err := os.WriteFile(profile, []byte(before), 0o600); err != nil { + t.Fatal(err) + } + + if err := addToShellPath("/f/bin", "/f/node-global/current/bin"); err != nil { + t.Fatal(err) + } + // A second install with other directories rewrites the line instead of adding another. + if err := addToShellPath("/f/bin"); err != nil { + t.Fatal(err) + } + data, _ := os.ReadFile(profile) + if strings.Count(string(data), profileMarker) != 1 || !strings.Contains(string(data), `export PATH="/f/bin":"$PATH"`) { + t.Fatalf("profile after install:\n%s", data) + } + + removed, err := removeFromShellProfiles() + if err != nil || !removed { + t.Fatalf("removed %v, err %v", removed, err) + } + data, _ = os.ReadFile(profile) + if string(data) != before { + t.Fatalf("profile after uninstall:\n%q\nwant\n%q", data, before) + } + if info, _ := os.Stat(profile); info.Mode().Perm() != 0o600 { + t.Fatalf("permissions changed to %v", info.Mode().Perm()) + } + // The optional profile didn't exist, and none is created. + if _, err := os.Stat(optional); !os.IsNotExist(err) { + t.Fatalf("%s: %v", shellProfiles[1], err) + } + + if removed, err := removeFromShellProfiles(); err != nil || removed { + t.Fatalf("second removal: removed %v, err %v", removed, err) + } +} From df13d6c9cc4a19f0cc49251c1162e7ecec22996a Mon Sep 17 00:00:00 2001 From: Sinan Eldem Date: Mon, 19 Oct 2026 02:59:03 +0300 Subject: [PATCH 06/26] feat(cli): flock install, uninstall and restart on Linux --- internal/cli/install.go | 5 +- internal/cli/install_linux.go | 530 +++++++++++++++++++++++++++++ internal/cli/install_linux_test.go | 71 ++++ internal/cli/update_linux.go | 8 + 4 files changed, 613 insertions(+), 1 deletion(-) create mode 100644 internal/cli/install_linux.go create mode 100644 internal/cli/install_linux_test.go create mode 100644 internal/cli/update_linux.go diff --git a/internal/cli/install.go b/internal/cli/install.go index 50192a67..9f239193 100644 --- a/internal/cli/install.go +++ b/internal/cli/install.go @@ -19,6 +19,9 @@ import ( const composerURL = "https://getcomposer.org/download/latest-stable/composer.phar" +// serviceNote is what `flock install` reports once installService is done. +var serviceNote = "daemon runs in the background and starts at login" + func newInstallCommand() *cobra.Command { var skipPrivileged, privilegedOnly, ignoreConflicts bool @@ -97,7 +100,7 @@ func newInstallCommand() *cobra.Command { if err := installService(s.root, bin); err != nil { return fmt.Errorf("install background service: %w", err) } - cmd.Println("✓ daemon runs in the background and starts at login") + cmd.Println("✓ " + serviceNote) if skipPrivileged || privilegedFirst { return nil diff --git a/internal/cli/install_linux.go b/internal/cli/install_linux.go new file mode 100644 index 00000000..6a501dad --- /dev/null +++ b/internal/cli/install_linux.go @@ -0,0 +1,530 @@ +package cli + +import ( + "bytes" + "context" + "crypto/x509" + "encoding/binary" + "encoding/pem" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "regexp" + "slices" + "strconv" + "strings" + "syscall" + "time" + + "github.com/spf13/cobra" + "golang.org/x/sys/unix" + + "flock/internal/api" + "flock/internal/certs" + "flock/internal/dns" + "flock/internal/node" + "flock/internal/paths" +) + +const serviceUnit = "flock.service" + +// shellProfiles are the profiles addToShellPath writes to: ~/.bashrc always, ~/.zshrc if present. +var shellProfiles = []string{".bashrc", ".zshrc"} + +// unitPath is the systemd user unit that runs the daemon. +func unitPath() (string, error) { + dir, err := os.UserConfigDir() + if err != nil { + return "", err + } + return filepath.Join(dir, "systemd", "user", serviceUnit), nil +} + +// systemdUser reports whether a systemd user manager runs for this user; containers and WSL +// without systemd have none. +func systemdUser() bool { + return exec.Command("systemctl", "--user", "show-environment").Run() == nil +} + +func systemctl(args ...string) error { + out, err := exec.Command("systemctl", args...).CombinedOutput() + if err != nil { + return fmt.Errorf("systemctl %s: %v: %s", strings.Join(args, " "), err, bytes.TrimSpace(out)) + } + return nil +} + +// installService runs the daemon as a systemd user service, which starts it at login. Without a +// systemd user manager it starts the daemon directly, and nothing starts it after a reboot. +func installService(root, bin string) error { + logFile := filepath.Join(root, "log", "daemon.log") + if err := os.MkdirAll(filepath.Dir(logFile), 0o700); err != nil { + return err + } + if !systemdUser() { + serviceNote = "daemon runs in the background; without a systemd user session nothing starts it at login, so run `flock restart` after a reboot" + stopDaemon(root) + return startDetached(root, bin) + } + + path, err := unitPath() + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + if err := os.WriteFile(path, []byte(unitFile(root, bin, logFile)), 0o644); err != nil { + return err + } + // A daemon started without systemd (by an earlier install) would keep the service from starting. + if systemctl("--user", "is-active", "--quiet", serviceUnit) != nil { + stopDaemon(root) + } + for _, args := range [][]string{{"daemon-reload"}, {"enable", serviceUnit}, {"restart", serviceUnit}} { + if err := systemctl(append([]string{"--user"}, args...)...); err != nil { + return err + } + } + return nil +} + +// unitFile is the daemon's systemd unit. A second daemon exits with exitAlreadyRunning, which +// isn't worth restarting; KillMode=mixed lets the daemon stop its services (PostgreSQL, PHP-FPM, +// ...) itself before systemd kills what is left. +func unitFile(root, bin, logFile string) string { + return fmt.Sprintf(`# Managed by Flock (flock install) +[Unit] +Description=Flock daemon + +[Service] +ExecStart=%s daemon --log-file %s +Environment=%s %s +Restart=always +RestartPreventExitStatus=%d +KillMode=mixed + +[Install] +WantedBy=default.target +`, unitArg(bin), unitArg(logFile), unitEnv("PATH="+servicePath(root)), unitEnv("FLOCK_HOME="+root), exitAlreadyRunning) +} + +// unitArg quotes s as one word of ExecStart, where systemd expands specifiers (%) and variables ($). +func unitArg(s string) string { + return `"` + strings.NewReplacer(`\`, `\\`, `"`, `\"`, "%", "%%", "$", "$$").Replace(s) + `"` +} + +// unitEnv quotes an assignment of Environment=, where systemd expands specifiers (%). +func unitEnv(s string) string { + return `"` + strings.NewReplacer(`\`, `\\`, `"`, `\"`, "%", "%%").Replace(s) + `"` +} + +// servicePath is the daemon's PATH: Flock's shims and the default Node.js version's global packages +// first, in the order `flock install` puts them on the shell's PATH, then the system's directories. +func servicePath(root string) string { + return strings.Join([]string{filepath.Join(root, "bin"), node.GlobalBin(node.CurrentGlobalDir(root)), + "/usr/local/sbin", "/usr/local/bin", "/usr/sbin", "/usr/bin", "/sbin", "/bin"}, ":") +} + +// startDetached starts the daemon in a session of its own, so it outlives the terminal that ran flock. +func startDetached(root, bin string) error { + cmd := exec.Command(bin, "daemon", "--log-file", filepath.Join(root, "log", "daemon.log")) + cmd.Env = append(os.Environ(), "PATH="+servicePath(root), "FLOCK_HOME="+root) + cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} + if err := cmd.Start(); err != nil { + return err + } + return cmd.Process.Release() +} + +// stopDaemon ends the running daemon and waits (up to 30 s, while it stops its services) until it +// is gone. +func stopDaemon(root string) { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + st, err := api.NewClient(paths.Socket(root)).Status(ctx) + if err != nil || st.PID <= 0 { + return + } + _ = syscall.Kill(st.PID, syscall.SIGTERM) + for deadline := time.Now().Add(30 * time.Second); time.Now().Before(deadline) && !processGone(st.PID); { + time.Sleep(100 * time.Millisecond) + } +} + +// processGone reports whether pid has ended. A zombie counts as gone: a detached daemon's parent +// can be a container's init that never reaps it. +func processGone(pid int) bool { + data, err := os.ReadFile("/proc/" + strconv.Itoa(pid) + "/stat") + if err != nil { + return true + } + _, after, _ := strings.Cut(string(data), ") ") + return strings.HasPrefix(after, "Z") +} + +// serviceInstalled reports whether the daemon's systemd unit is installed. +func serviceInstalled() bool { + path, err := unitPath() + if err != nil { + return false + } + _, err = os.Stat(path) + return err == nil +} + +func uninstallService() error { + path, err := unitPath() + if err != nil { + return err + } + if _, err := os.Stat(path); err == nil { + if systemdUser() { + _ = systemctl("--user", "disable", "--now", serviceUnit) + } + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + return err + } + if systemdUser() { + _ = systemctl("--user", "daemon-reload") + } + } + // Also a daemon started without systemd. + if root, err := paths.Root(); err == nil { + stopDaemon(root) + } + return nil +} + +// restartService has systemd restart the daemon, or restarts a daemon started without systemd. +func restartService() error { + root, err := paths.Root() + if err != nil { + return err + } + if serviceInstalled() && systemdUser() { + return systemctl("--user", "restart", serviceUnit) + } + bin := filepath.Join(root, "bin", exeName("flock")) + if _, err := os.Stat(bin); err != nil { + return fmt.Errorf("the background daemon is not installed — run `flock install`") + } + stopDaemon(root) + return startDetached(root, bin) +} + +// privilegedFirst: `flock install` runs ensurePrivileged last, as the installed flock binary. +const privilegedFirst = false + +// installHelperBinary: Linux has no hosts helper. +func installHelperBinary(string) error { return nil } + +// ensurePrivileged checks what needs root (systemd-resolved's drop-in for the TLD, the CA in the +// system's trust store, listening on ports below 1024) and does what is missing in one sudo run of +// privileged-setup. Browsers keep their own trust stores, which the user can change. +func ensurePrivileged(cmd *cobra.Command, s *state, bin string, ca *certs.CA) error { + tld, dnsPort := s.cfg.TLD, s.cfg.Ports.DNS + var args []string + resolved := dns.ResolvedRunning() + if resolved && dns.ResolvedProblem(tld, dnsPort) != "" { + args = append(args, "--tld", tld, "--dns-port", strconv.Itoa(dnsPort)) + } + if !systemTrusts(ca.CertPath()) { + args = append(args, "--ca", ca.CertPath()) + } + lowPorts := os.Geteuid() != 0 && needsLowPorts(s.cfg.Ports.HTTP, s.cfg.Ports.HTTPS) && !canBindLowPorts(bin) + if lowPorts { + args = append(args, "--bind-low-ports", bin) + } + + if len(args) > 0 { + cmd.Printf("→ privileged-setup %s (as root: sudo may ask for your password)…\n", strings.Join(args, " ")) + if err := runPrivileged(cmd, bin, append([]string{"privileged-setup"}, args...)...); err != nil { + return fmt.Errorf("privileged setup: %w", err) + } + } + if resolved { + cmd.Printf("✓ *.%s resolves via Flock\n", tld) + } else { + cmd.Printf("! systemd-resolved isn't running: for *.%s, have your DNS resolver forward .%s to 127.0.0.1 port %d\n", tld, tld, dnsPort) + } + cmd.Println("✓ Flock CA is trusted") + trustInBrowsers(cmd, ca.CertPath()) + + if lowPorts { + // The capability applies to processes started after it was set. + if err := restartService(); err != nil { + return fmt.Errorf("restart the daemon: %w", err) + } + cmd.Printf("✓ the daemon may listen on ports %d and %d\n", s.cfg.Ports.HTTP, s.cfg.Ports.HTTPS) + } + return nil +} + +// runPrivileged runs a command as root: directly when flock already runs as root, else through +// sudo, which asks for the password at the terminal. +func runPrivileged(cmd *cobra.Command, exe string, args ...string) error { + run := exec.Command("sudo", append([]string{exe}, args...)...) + if os.Geteuid() == 0 { + run = exec.Command(exe, args...) + } + run.Stdin, run.Stdout, run.Stderr = os.Stdin, cmd.OutOrStdout(), cmd.ErrOrStderr() + return run.Run() +} + +// systemTrusts reports whether the system's trust store has the CA, the way Go, curl and PHP see it. +func systemTrusts(caPath string) bool { + data, err := os.ReadFile(caPath) + if err != nil { + return false + } + block, _ := pem.Decode(data) + if block == nil { + return false + } + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return false + } + _, err = cert.Verify(x509.VerifyOptions{}) + return err == nil +} + +// needsLowPorts reports whether listening on one of ports takes CAP_NET_BIND_SERVICE here. +func needsLowPorts(ports ...int) bool { + start := 1024 + if data, err := os.ReadFile("/proc/sys/net/ipv4/ip_unprivileged_port_start"); err == nil { + if n, err := strconv.Atoi(strings.TrimSpace(string(data))); err == nil { + start = n + } + } + return slices.Min(ports) < start +} + +// canBindLowPorts reports whether bin's file capabilities make CAP_NET_BIND_SERVICE permitted and +// effective. +func canBindLowPorts(bin string) bool { + buf := make([]byte, 24) + n, err := unix.Getxattr(bin, "security.capability", buf) + return err == nil && bindCapability(buf[:n]) +} + +// bindCapability reads a security.capability attribute (struct vfs_cap_data): the flags, then the +// permitted set's low 32 bits. +func bindCapability(data []byte) bool { + const flagsEffective = 0x1 // VFS_CAP_FLAGS_EFFECTIVE + if len(data) < 8 || binary.LittleEndian.Uint32(data[0:4])&flagsEffective == 0 { + return false + } + return binary.LittleEndian.Uint32(data[4:8])&(1< 0 { + if tld == "" { + return fmt.Errorf("--dns-port needs --tld") + } + if err := os.MkdirAll(dns.ResolvedDir, 0o755); err != nil { + return err + } + if err := os.WriteFile(dns.ResolvedFile(tld), []byte(dns.ResolvedContent(tld, dnsPort)), 0o644); err != nil { + return err + } + if err := runTool("systemctl", "try-restart", "systemd-resolved"); err != nil { + return err + } + } + if caPath != "" { + anchor, update, err := caAnchor() + if err != nil { + return err + } + data, err := os.ReadFile(caPath) + if err != nil { + return err + } + if err := os.WriteFile(anchor, data, 0o644); err != nil { + return err + } + if err := runTool(update[0], update[1:]...); err != nil { + return err + } + } + if lowPortsBin != "" { + if !filepath.IsAbs(lowPortsBin) { + return fmt.Errorf("invalid --bind-low-ports %q", lowPortsBin) + } + return runTool("setcap", "cap_net_bind_service=+ep", lowPortsBin) + } + return nil + }, + } + + cmd.Flags().StringVar(&tld, "tld", "", "") + cmd.Flags().IntVar(&dnsPort, "dns-port", 0, "") + cmd.Flags().StringVar(&caPath, "ca", "", "") + cmd.Flags().StringVar(&lowPortsBin, "bind-low-ports", "", "") + cmd.Flags().BoolVar(&remove, "remove", false, "") + + return cmd +} + +func newUninstallCommand() *cobra.Command { + var purge bool + cmd := &cobra.Command{ + Use: "uninstall", + Short: "Remove Flock: daemon, PATH entry, DNS/CA trust and downloaded files (keeps your sites' settings and databases unless --purge)", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + s, err := load() + if err != nil { + return err + } + purge = decidePurge(s.root, purge) + if err := uninstallService(); err != nil { + return err + } + cmd.Println("✓ background daemon removed") + + switch removed, err := removeFromShellProfiles(); { + case err != nil: + cmd.Printf("! could not update your shell profile (%v) — remove the PATH line marked %q\n", err, profileMarker) + case removed: + cmd.Println("✓ removed Flock from your PATH (open a new terminal)") + } + + caPath := filepath.Join(s.root, "certs", "ca.crt") + untrustInBrowsers(caPath) + self, _ := os.Executable() + if err := runPrivileged(cmd, self, "privileged-setup", "--remove", "--tld", s.cfg.TLD); err != nil { + return err + } + cmd.Printf("✓ removed %s and the Flock CA's trust\n", dns.ResolvedFile(s.cfg.TLD)) + + return finishUninstall(cmd, s.root, purge) + }, + } + uninstallFlags(cmd, &purge) + return cmd +} + +// appDirs: Flock has no app on Linux. +func appDirs() []string { return nil } + +// removeAfterExit is never needed: Linux deletes running programs. +func removeAfterExit(string) bool { return false } diff --git a/internal/cli/install_linux_test.go b/internal/cli/install_linux_test.go new file mode 100644 index 00000000..6200c639 --- /dev/null +++ b/internal/cli/install_linux_test.go @@ -0,0 +1,71 @@ +package cli + +import ( + "encoding/binary" + "os" + "os/exec" + "slices" + "strings" + "testing" + "time" +) + +// Paths with spaces, quotes, % and $ reach the daemon as they are. +func TestUnitFileQuotesPaths(t *testing.T) { + unit := unitFile(`/home/o"b/50% $x/Flock`, `/home/o"b/50% $x/Flock/bin/flock`, "/l/daemon.log") + want := `ExecStart="/home/o\"b/50%% $$x/Flock/bin/flock" daemon --log-file "/l/daemon.log"` + if !strings.Contains(unit, want+"\n") { + t.Fatalf("unit:\n%s\nwant the line\n%s", unit, want) + } + if !strings.Contains(unit, `"FLOCK_HOME=/home/o\"b/50%% $x/Flock"`) { + t.Fatalf("unit has no FLOCK_HOME:\n%s", unit) + } +} + +// The daemon finds the same commands as a terminal: Flock's shims, then global npm packages. +func TestServicePathMatchesTheShellPath(t *testing.T) { + got := strings.Split(servicePath("/f"), ":") + if !slices.Equal(got[:2], []string{"/f/bin", "/f/node-global/current/bin"}) || !slices.Contains(got, "/usr/bin") { + t.Fatalf("PATH %v", got) + } +} + +// A detached daemon that has exited counts as gone even when nobody reaps it. +func TestProcessGoneTreatsZombiesAsGone(t *testing.T) { + if processGone(os.Getpid()) { + t.Fatal("this process is gone") + } + cmd := exec.Command("true") + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + defer cmd.Wait() + for deadline := time.Now().Add(5 * time.Second); !processGone(cmd.Process.Pid); time.Sleep(10 * time.Millisecond) { + if time.Now().After(deadline) { + t.Fatal("an exited, unreaped process isn't gone") + } + } +} + +func TestBindCapability(t *testing.T) { + attr := func(flags, permitted uint32) []byte { + b := make([]byte, 20) + binary.LittleEndian.PutUint32(b[0:4], 0x02000000|flags) // VFS_CAP_REVISION_2 + binary.LittleEndian.PutUint32(b[4:8], permitted) + return b + } + for _, c := range []struct { + data []byte + want bool + }{ + {attr(1, 1<<10), true}, // cap_net_bind_service=+ep + {attr(0, 1<<10), false}, // +p only: not raised at exec + {attr(1, 1<<12), false}, // another capability + {attr(1, 1<<10|1<<12)[:4], false}, // truncated + {nil, false}, + } { + if got := bindCapability(c.data); got != c.want { + t.Errorf("bindCapability(%x) = %v, want %v", c.data, got, c.want) + } + } +} diff --git a/internal/cli/update_linux.go b/internal/cli/update_linux.go new file mode 100644 index 00000000..d8cdcfc8 --- /dev/null +++ b/internal/cli/update_linux.go @@ -0,0 +1,8 @@ +package cli + +import "errors" + +// openUpdateLink: Flock has no app on Linux to update it. +func openUpdateLink() error { + return errors.New("there is no Flock app on Linux: get the new flock from https://github.com/flock-php/flock/releases and run its `flock install`") +} From befd5e3f0f09c73719358650b64b52fa6b7d9a75 Mon Sep 17 00:00:00 2001 From: Sinan Eldem Date: Mon, 19 Oct 2026 03:00:30 +0300 Subject: [PATCH 07/26] ci: run the Go tests on Linux and build the CLI for it --- .github/workflows/build.yml | 4 +++- Makefile | 2 ++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index dc172f64..7fdae2cc 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -64,7 +64,7 @@ jobs: fi echo "version=$version" >> "$GITHUB_OUTPUT" - # The Go tests on both platforms Flock supports (the race detector needs cgo, so macOS only). + # The Go tests on the platforms Flock supports (the race detector needs cgo, so not on Windows). go-test: name: Go tests (${{ matrix.name }}) needs: version @@ -74,6 +74,8 @@ jobs: include: - { name: macOS, os: macos-26, flags: -race } - { name: Windows, os: windows-2025, flags: "" } + - { name: Linux, os: ubuntu-24.04, flags: -race } + - { name: Linux arm64, os: ubuntu-24.04-arm, flags: -race } runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v7 diff --git a/Makefile b/Makefile index 0c70f37b..5d682bb2 100644 --- a/Makefile +++ b/Makefile @@ -9,6 +9,8 @@ build: build-all: GOOS=darwin GOARCH=arm64 go build -ldflags "$(LDFLAGS)" -o dist/flock-darwin-arm64 ./cmd/flock GOOS=windows GOARCH=amd64 go build -ldflags "$(LDFLAGS)" -o dist/flock-windows-amd64.exe ./cmd/flock + GOOS=linux GOARCH=amd64 go build -ldflags "$(LDFLAGS)" -o dist/flock-linux-amd64 ./cmd/flock + GOOS=linux GOARCH=arm64 go build -ldflags "$(LDFLAGS)" -o dist/flock-linux-arm64 ./cmd/flock test: go test -race ./... From c195af15a287b95acbb5329d473ad18f080a5ec6 Mon Sep 17 00:00:00 2001 From: Sinan Eldem Date: Mon, 19 Oct 2026 06:36:06 +0300 Subject: [PATCH 08/26] fix(ports): find a port's holder through /proc on Linux, without lsof --- internal/ports/holders_linux_test.go | 25 ++++++++++++ internal/ports/holders_unix.go | 60 +++++++++++++++++++++++++++- 2 files changed, 84 insertions(+), 1 deletion(-) create mode 100644 internal/ports/holders_linux_test.go diff --git a/internal/ports/holders_linux_test.go b/internal/ports/holders_linux_test.go new file mode 100644 index 00000000..fd37d563 --- /dev/null +++ b/internal/ports/holders_linux_test.go @@ -0,0 +1,25 @@ +package ports + +import ( + "net" + "os" + "testing" +) + +// Without lsof, /proc still names the process listening on a port. +func TestProcListenersFindsThisProcess(t *testing.T) { + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer ln.Close() + port := ln.Addr().(*net.TCPAddr).Port + + found := procListeners(port) + if len(found) != 1 || found[0].PID != os.Getpid() || found[0].Name == "" { + t.Fatalf("procListeners(%d) = %+v, want this process (pid %d)", port, found, os.Getpid()) + } + if found := procListeners(port + 1); len(found) != 0 && found[0].PID == os.Getpid() { + t.Fatalf("procListeners on another port found this process: %+v", found) + } +} diff --git a/internal/ports/holders_unix.go b/internal/ports/holders_unix.go index 1422e001..56e0cdce 100644 --- a/internal/ports/holders_unix.go +++ b/internal/ports/holders_unix.go @@ -3,11 +3,69 @@ package ports import ( + "bufio" "os" + "path/filepath" "strconv" + "strings" ) -func listeners(port int) []listener { return lsofListeners(port) } +// listeners asks /proc first: lsof isn't installed everywhere on Linux (containers, minimal +// systems), and without it Flock's own daemon would look like another program. +func listeners(port int) []listener { + if found := procListeners(port); len(found) > 0 { + return found + } + return lsofListeners(port) +} + +// procListeners finds the listening TCP sockets on port in /proc/net and the processes holding +// them through /proc//fd. Like lsof, it only sees the user's own processes without root; a +// socket whose process it can't see is still reported, without a PID. +func procListeners(port int) []listener { + inodes := map[string]bool{} + for _, table := range []string{"/proc/net/tcp", "/proc/net/tcp6"} { + f, err := os.Open(table) + if err != nil { + continue + } + lines := bufio.NewScanner(f) + lines.Scan() // the header + for lines.Scan() { + // sl local_address rem_address st ... inode: "0: 0100007F:1F90 00000000:0000 0A ..." + fields := strings.Fields(lines.Text()) + if len(fields) < 10 || fields[3] != "0A" { // 0A: LISTEN + continue + } + _, hexPort, _ := strings.Cut(fields[1], ":") + if p, err := strconv.ParseInt(hexPort, 16, 32); err == nil && int(p) == port { + inodes[fields[9]] = true + } + } + f.Close() + } + if len(inodes) == 0 { + return nil + } + + var found []listener + fds, _ := filepath.Glob("/proc/[0-9]*/fd/*") + for _, fd := range fds { + target, err := os.Readlink(fd) + inode := strings.TrimSuffix(strings.TrimPrefix(target, "socket:["), "]") + if err != nil || !inodes[inode] { + continue + } + delete(inodes, inode) + pid, _ := strconv.Atoi(strings.Split(fd, "/")[2]) + name, _ := os.ReadFile("/proc/" + strconv.Itoa(pid) + "/comm") + found = append(found, listener{PID: pid, Name: strings.TrimSpace(string(name))}) + } + for range inodes { + found = append(found, listener{}) // another user's process + } + return found +} func executable(pid int) string { path, _ := os.Readlink("/proc/" + strconv.Itoa(pid) + "/exe") From 838ed20f83abbf0f235af46e5ed3b8bd355af9f3 Mon Sep 17 00:00:00 2001 From: Sinan Eldem Date: Mon, 19 Oct 2026 11:06:21 +0300 Subject: [PATCH 09/26] fix(cli): name the Linux CLI flock-php, util-linux owns flock(1) --- internal/cli/install.go | 3 ++- internal/cli/install_linux.go | 10 ++++++---- internal/cli/update_linux.go | 2 +- internal/mcpclients/mcpclients.go | 7 +++---- internal/paths/paths.go | 14 ++++++++++++++ internal/ports/ports.go | 2 +- 6 files changed, 27 insertions(+), 11 deletions(-) diff --git a/internal/cli/install.go b/internal/cli/install.go index 9f239193..bbc1c6b7 100644 --- a/internal/cli/install.go +++ b/internal/cli/install.go @@ -14,6 +14,7 @@ import ( "flock/internal/certs" "flock/internal/node" + "flock/internal/paths" "flock/internal/php" ) @@ -149,7 +150,7 @@ func installBinary(root string) (string, error) { return "", err } - dst := filepath.Join(root, "bin", exeName("flock")) + dst := filepath.Join(root, "bin", paths.CLI()) if self == dst { return dst, nil } diff --git a/internal/cli/install_linux.go b/internal/cli/install_linux.go index 6a501dad..14d8a318 100644 --- a/internal/cli/install_linux.go +++ b/internal/cli/install_linux.go @@ -63,8 +63,10 @@ func installService(root, bin string) error { if err := os.MkdirAll(filepath.Dir(logFile), 0o700); err != nil { return err } + // Installs from before the CLI became flock-php left a bin/flock that shadows util-linux's. + _ = os.Remove(filepath.Join(root, "bin", "flock")) if !systemdUser() { - serviceNote = "daemon runs in the background; without a systemd user session nothing starts it at login, so run `flock restart` after a reboot" + serviceNote = "daemon runs in the background; without a systemd user session nothing starts it at login, so run `flock-php restart` after a reboot" stopDaemon(root) return startDetached(root, bin) } @@ -207,9 +209,9 @@ func restartService() error { if serviceInstalled() && systemdUser() { return systemctl("--user", "restart", serviceUnit) } - bin := filepath.Join(root, "bin", exeName("flock")) + bin := filepath.Join(root, "bin", paths.CLI()) if _, err := os.Stat(bin); err != nil { - return fmt.Errorf("the background daemon is not installed — run `flock install`") + return fmt.Errorf("the background daemon is not installed — run `flock-php install`") } stopDaemon(root) return startDetached(root, bin) @@ -351,7 +353,7 @@ func trustInBrowsers(cmd *cobra.Command, caPath string) { return } if _, err := exec.LookPath("certutil"); err != nil { - cmd.Println("! Chrome and Firefox don't trust Flock's CA yet: install certutil (libnss3-tools or nss-tools) and run `flock install` again") + cmd.Println("! Chrome and Firefox don't trust Flock's CA yet: install certutil (libnss3-tools or nss-tools) and run `flock-php install` again") return } nickname := nssNickname(caPath) diff --git a/internal/cli/update_linux.go b/internal/cli/update_linux.go index d8cdcfc8..c2d041cd 100644 --- a/internal/cli/update_linux.go +++ b/internal/cli/update_linux.go @@ -4,5 +4,5 @@ import "errors" // openUpdateLink: Flock has no app on Linux to update it. func openUpdateLink() error { - return errors.New("there is no Flock app on Linux: get the new flock from https://github.com/flock-php/flock/releases and run its `flock install`") + return errors.New("there is no Flock app on Linux: get the new flock-php from https://github.com/flock-php/flock/releases and run its `flock-php install`") } diff --git a/internal/mcpclients/mcpclients.go b/internal/mcpclients/mcpclients.go index 4ef44037..243b9750 100644 --- a/internal/mcpclients/mcpclients.go +++ b/internal/mcpclients/mcpclients.go @@ -16,6 +16,8 @@ import ( "path/filepath" "runtime" "strings" + + "flock/internal/paths" ) // Name is the server's name in the clients' configs. @@ -79,10 +81,7 @@ func DefaultEnv(root string) (Env, error) { // Executable is the flock binary clients should start: the one `flock install` copied into // /bin, which stays put across updates, or else the running one. func Executable(root string) string { - exe := "flock" - if runtime.GOOS == "windows" { - exe += ".exe" - } + exe := paths.CLI() if installed := filepath.Join(root, "bin", exe); fileExists(installed) { return installed } diff --git a/internal/paths/paths.go b/internal/paths/paths.go index aa3a211e..b56c3f8f 100644 --- a/internal/paths/paths.go +++ b/internal/paths/paths.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "path/filepath" + "runtime" ) // Root returns Flock's data directory: @@ -23,6 +24,19 @@ func Root() (string, error) { return filepath.Join(base, "Flock"), nil } +// CLI is the flock executable's name in /bin, the directory `flock install` puts first on +// PATH. On Linux it is flock-php: util-linux's flock(1) is in /usr/bin everywhere, and scripts +// that lock with it would get Flock's CLI instead. +func CLI() string { + switch runtime.GOOS { + case "windows": + return "flock.exe" + case "linux": + return "flock-php" + } + return "flock" +} + // Socket is the daemon's API socket. AF_UNIX works on macOS and Windows 10+. func Socket(root string) string { return filepath.Join(root, "flockd.sock") diff --git a/internal/ports/ports.go b/internal/ports/ports.go index df2dfc56..8f4e3504 100644 --- a/internal/ports/ports.go +++ b/internal/ports/ports.go @@ -145,7 +145,7 @@ func Own(h Holder, root string) bool { if h.Path != "" { name = strings.TrimSuffix(strings.ToLower(baseName(h.Path)), ".exe") } - return name == "flock" || name == "flockd" + return name == "flock" || name == "flock-php" || name == "flockd" } // within reports whether path is inside dir, comparing case-insensitively (macOS' and Windows' From 043d519365e28aeede5d4cd0abf65a5981df8950 Mon Sep 17 00:00:00 2001 From: Sinan Eldem Date: Mon, 19 Oct 2026 11:06:28 +0300 Subject: [PATCH 10/26] fix(dns): route the TLD through a link of Flock's own on Linux --- internal/cli/install_linux.go | 70 ++++++++++++++++++++++++-------- internal/daemon/wildcard.go | 4 +- internal/daemon/wildcard_test.go | 4 +- internal/dns/resolved.go | 64 +++++++++++++++++++++++------ internal/dns/resolved_test.go | 42 ++++++++++++++----- 5 files changed, 140 insertions(+), 44 deletions(-) diff --git a/internal/cli/install_linux.go b/internal/cli/install_linux.go index 14d8a318..7b05e1c5 100644 --- a/internal/cli/install_linux.go +++ b/internal/cli/install_linux.go @@ -3,11 +3,14 @@ package cli import ( "bytes" "context" + "crypto/rand" "crypto/x509" "encoding/binary" + "encoding/hex" "encoding/pem" "errors" "fmt" + "net" "os" "os/exec" "path/filepath" @@ -223,9 +226,10 @@ const privilegedFirst = false // installHelperBinary: Linux has no hosts helper. func installHelperBinary(string) error { return nil } -// ensurePrivileged checks what needs root (systemd-resolved's drop-in for the TLD, the CA in the -// system's trust store, listening on ports below 1024) and does what is missing in one sudo run of -// privileged-setup. Browsers keep their own trust stores, which the user can change. +// ensurePrivileged checks what needs root (the unit that routes the TLD to Flock through +// systemd-resolved, the CA in the system's trust store, listening on ports below 1024) and does +// what is missing in one sudo run of privileged-setup. Browsers keep their own trust stores, +// which the user can change. func ensurePrivileged(cmd *cobra.Command, s *state, bin string, ca *certs.CA) error { tld, dnsPort := s.cfg.TLD, s.cfg.Ports.DNS var args []string @@ -247,10 +251,13 @@ func ensurePrivileged(cmd *cobra.Command, s *state, bin string, ca *certs.CA) er return fmt.Errorf("privileged setup: %w", err) } } - if resolved { - cmd.Printf("✓ *.%s resolves via Flock\n", tld) - } else { + switch { + case !resolved: cmd.Printf("! systemd-resolved isn't running: for *.%s, have your DNS resolver forward .%s to 127.0.0.1 port %d\n", tld, tld, dnsPort) + case resolvesToFlock(tld): + cmd.Printf("✓ *.%s resolves via Flock\n", tld) + default: + cmd.Printf("! *.%s doesn't resolve via Flock yet: see `systemctl status %s` and `flock-php status`\n", tld, dns.Unit) } cmd.Println("✓ Flock CA is trusted") trustInBrowsers(cmd, ca.CertPath()) @@ -265,6 +272,23 @@ func ensurePrivileged(cmd *cobra.Command, s *state, bin string, ca *certs.CA) er return nil } +// resolvesToFlock looks up fresh names under tld through the system resolver, as browsers and PHP +// do, until one answers 127.0.0.1 (for up to ~10 s: the daemon may still be starting its DNS server). +func resolvesToFlock(tld string) bool { + for range 10 { + nonce := make([]byte, 6) + _, _ = rand.Read(nonce) + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + addrs, err := (&net.Resolver{PreferGo: false}).LookupHost(ctx, "flock-install-"+hex.EncodeToString(nonce)+"."+tld) + cancel() + if err == nil && slices.Contains(addrs, "127.0.0.1") { + return true + } + time.Sleep(500 * time.Millisecond) + } + return false +} + // runPrivileged runs a command as root: directly when flock already runs as root, else through // sudo, which asks for the password at the terminal. func runPrivileged(cmd *cobra.Command, exe string, args ...string) error { @@ -379,7 +403,7 @@ func untrustInBrowsers(caPath string) { } } -// tldPattern is a TLD privileged-setup writes into systemd-resolved's configuration. +// tldPattern is a TLD privileged-setup writes into the unit that routes it to Flock. var tldPattern = regexp.MustCompile(`^[A-Za-z0-9-]{1,63}$`) // caAnchor is where the system's trust store takes another CA, and the command that rebuilds the @@ -405,6 +429,16 @@ func runTool(name string, args ...string) error { return nil } +// removeLegacyDropIn removes the resolved.conf drop-in of installs before dns.Link, which put Flock +// among resolved's global DNS servers. +func removeLegacyDropIn(tld string) { + file := dns.LegacyDropIn(tld) + if data, _ := os.ReadFile(file); tld != "" && strings.HasPrefix(string(data), "# Managed by Flock") { + _ = os.Remove(file) + _ = runTool("systemctl", "try-restart", "systemd-resolved") + } +} + func newPrivilegedSetupCommand() *cobra.Command { var tld, caPath, lowPortsBin string var dnsPort int @@ -424,9 +458,11 @@ func newPrivilegedSetupCommand() *cobra.Command { } if remove { - if data, _ := os.ReadFile(dns.ResolvedFile(tld)); tld != "" && strings.HasPrefix(string(data), "# Managed by Flock") { - _ = os.Remove(dns.ResolvedFile(tld)) - _ = runTool("systemctl", "try-restart", "systemd-resolved") + removeLegacyDropIn(tld) + if data, _ := os.ReadFile(dns.ResolvedFile()); strings.HasPrefix(string(data), "# Managed by Flock") { + _ = runTool("systemctl", "disable", "--now", dns.Unit) // its ExecStop deletes the link + _ = os.Remove(dns.ResolvedFile()) + _ = runTool("systemctl", "daemon-reload") } if anchor, update, err := caAnchor(); err == nil { if _, err := os.Stat(anchor); err == nil { @@ -441,14 +477,14 @@ func newPrivilegedSetupCommand() *cobra.Command { if tld == "" { return fmt.Errorf("--dns-port needs --tld") } - if err := os.MkdirAll(dns.ResolvedDir, 0o755); err != nil { - return err - } - if err := os.WriteFile(dns.ResolvedFile(tld), []byte(dns.ResolvedContent(tld, dnsPort)), 0o644); err != nil { + removeLegacyDropIn(tld) + if err := os.WriteFile(dns.ResolvedFile(), []byte(dns.ResolvedContent(tld, dnsPort)), 0o644); err != nil { return err } - if err := runTool("systemctl", "try-restart", "systemd-resolved"); err != nil { - return err + for _, args := range [][]string{{"daemon-reload"}, {"enable", dns.Unit}, {"restart", dns.Unit}} { + if err := runTool("systemctl", args...); err != nil { + return err + } } } if caPath != "" { @@ -516,7 +552,7 @@ func newUninstallCommand() *cobra.Command { if err := runPrivileged(cmd, self, "privileged-setup", "--remove", "--tld", s.cfg.TLD); err != nil { return err } - cmd.Printf("✓ removed %s and the Flock CA's trust\n", dns.ResolvedFile(s.cfg.TLD)) + cmd.Printf("✓ removed %s and the Flock CA's trust\n", dns.ResolvedFile()) return finishUninstall(cmd, s.root, purge) }, diff --git a/internal/daemon/wildcard.go b/internal/daemon/wildcard.go index 3930ea9f..059dd331 100644 --- a/internal/daemon/wildcard.go +++ b/internal/daemon/wildcard.go @@ -38,7 +38,7 @@ func (d *daemon) recheckWildcard() { // checkWildcard looks up a random name under the TLD through the system resolver, the way browsers // and PHP do, and checks that the query reached Flock's DNS server. On Windows that only happens // when the NRPT rule from `flock install` is in effect, on macOS when /etc/resolver/ is, on -// Linux when systemd-resolved has the drop-in from `flock install` (or another resolver forwards +// Linux when the unit from `flock install` gives systemd-resolved Flock's link (or another resolver forwards // the TLD); Group Policy, VPN clients and security software can override them without Flock // noticing otherwise. func (d *daemon) checkWildcard() { @@ -116,5 +116,5 @@ func linuxWildcardProblem(tld string, port int) string { if problem := dns.ResolvedProblem(tld, port); problem != "" { return problem } - return fmt.Sprintf("Linux doesn't send .%s lookups to Flock (a VPN or DNS filter may be overriding systemd-resolved, or /etc/resolv.conf doesn't use it)", tld) + return fmt.Sprintf("Linux doesn't send .%s lookups to Flock (see `systemctl status %s`; a VPN or DNS filter may be overriding systemd-resolved, or /etc/resolv.conf doesn't use it)", tld, dns.Unit) } diff --git a/internal/daemon/wildcard_test.go b/internal/daemon/wildcard_test.go index 6eb7ae9c..bebac469 100644 --- a/internal/daemon/wildcard_test.go +++ b/internal/daemon/wildcard_test.go @@ -25,14 +25,14 @@ func init() { windowsDNSPort = ln.LocalAddr().(*net.UDPAddr).Port _ = ln.Close() - // The real /etc/resolver and systemd-resolved drop-in point at the real daemon, never at a + // The real /etc/resolver and systemd unit point at the real daemon, never at a // test daemon's port. dir, err := os.MkdirTemp("", "flock-resolver-") if err != nil { panic(err) } dns.ResolverDir = dir - dns.ResolvedDir = dir + dns.UnitDir = dir } func waitStatus(t *testing.T, client *api.Client, ok func(api.Status) bool) api.Status { diff --git a/internal/dns/resolved.go b/internal/dns/resolved.go index 6219496f..3e0f6f69 100644 --- a/internal/dns/resolved.go +++ b/internal/dns/resolved.go @@ -7,20 +7,60 @@ import ( "path/filepath" ) -// ResolvedDir is where systemd-resolved reads drop-in configuration (resolved.conf(5)). Tests move it. +// systemd-resolved sends a domain's lookups to the DNS servers of the link that claims it as a +// routing domain (~test). Flock gets a link of its own for that: Link, a dummy interface a oneshot +// system unit sets up at boot. A resolved.conf drop-in would put Flock among the global DNS +// servers instead, and when there are others (DNS= in resolved.conf), resolved switches to one of +// them after Flock refuses a name outside the TLD, and the TLD stops resolving. + +// UnitDir is where `flock install` writes the unit. Tests move it. +var UnitDir = "/etc/systemd/system" + +// ResolvedDir is where systemd-resolved reads drop-ins, where installs before Link kept one. Tests move it. var ResolvedDir = "/etc/systemd/resolved.conf.d" // ResolvedRuntimeDir exists while systemd-resolved runs. Tests move it. var ResolvedRuntimeDir = "/run/systemd/resolve" -// ResolvedFile is the drop-in `flock install` writes on Linux to send tld to Flock. -func ResolvedFile(tld string) string { return filepath.Join(ResolvedDir, "flock-"+tld+".conf") } +const ( + // Unit is the system unit that sets up Link. + Unit = "flock-dns.service" + // Link is the dummy interface whose DNS settings route the TLD to Flock. + Link = "flock0" + // linkAddress is there because resolved ignores links without a routable address. 198.18.0.0/15 + // is set aside for benchmarks, so no network uses it. + linkAddress = "198.18.53.53/32" +) + +// ResolvedFile is the unit `flock install` writes on Linux to send the TLD to Flock. +func ResolvedFile() string { return filepath.Join(UnitDir, Unit) } -// ResolvedContent is what ResolvedFile holds when it sends tld lookups to Flock's DNS server on -// port. ~tld routes those lookups to Flock alone. A global DNS server also gets asked for every -// other name, which Flock refuses and the network's DNS servers answer. +// LegacyDropIn is the resolved.conf drop-in installs before Link wrote for tld. +func LegacyDropIn(tld string) string { return filepath.Join(ResolvedDir, "flock-"+tld+".conf") } + +// ResolvedContent is the unit that sends tld lookups to Flock's DNS server on port. A link with +// only a routing domain and no explicit default route gets no other lookups. resolved learns of +// the new link through netlink, a moment after ip created it, hence the retries. func ResolvedContent(tld string, port int) string { - return fmt.Sprintf("# Managed by Flock\n[Resolve]\nDNS=127.0.0.1:%d\nDomains=~%s\n", port, tld) + return fmt.Sprintf(`# Managed by Flock +[Unit] +Description=Flock: send *.%[1]s lookups to Flock's DNS server +Wants=systemd-resolved.service +After=systemd-resolved.service + +[Service] +Type=oneshot +RemainAfterExit=yes +ExecStartPre=-ip link delete %[3]s +ExecStart=ip link add %[3]s type dummy +ExecStart=ip address add %[4]s dev %[3]s +ExecStart=ip link set %[3]s up +ExecStart=sh -c 'for i in 1 2 3 4 5 6 7 8 9 10; do resolvectl dns %[3]s 127.0.0.1:%[2]d && resolvectl domain %[3]s "~%[1]s" && exit 0; sleep 0.5; done; exit 1' +ExecStop=ip link delete %[3]s + +[Install] +WantedBy=multi-user.target +`, tld, port, Link, linkAddress) } // ResolvedRunning reports whether systemd-resolved runs on this machine. @@ -30,21 +70,21 @@ func ResolvedRunning() bool { } // ResolvedProblem explains why systemd-resolved does not send tld lookups to Flock's DNS server on -// port, or returns "" when its drop-in does. +// port, or returns "" when the unit is in place. func ResolvedProblem(tld string, port int) string { if !ResolvedRunning() { return fmt.Sprintf("systemd-resolved isn't running, so Linux doesn't send .%s lookups to Flock — "+ "have your DNS resolver (dnsmasq, NetworkManager, ...) forward .%s to 127.0.0.1 port %d", tld, tld, port) } - path := ResolvedFile(tld) + path := ResolvedFile() data, err := os.ReadFile(path) switch { case errors.Is(err, os.ErrNotExist): - return fmt.Sprintf("%s is missing, so systemd-resolved doesn't send .%s lookups to Flock — run `flock install`", path, tld) + return fmt.Sprintf("%s is missing, so systemd-resolved doesn't send .%s lookups to Flock — run `flock-php install`", path, tld) case err != nil: - return fmt.Sprintf("can't read %s (%v) — run `flock install`", path, err) + return fmt.Sprintf("can't read %s (%v) — run `flock-php install`", path, err) case string(data) != ResolvedContent(tld, port): - return fmt.Sprintf("%s doesn't point at Flock's DNS server on port %d — run `flock install`", path, port) + return fmt.Sprintf("%s doesn't send .%s lookups to Flock's DNS server on port %d — run `flock-php install`", path, tld, port) } return "" } diff --git a/internal/dns/resolved_test.go b/internal/dns/resolved_test.go index d59e0174..f9f80947 100644 --- a/internal/dns/resolved_test.go +++ b/internal/dns/resolved_test.go @@ -8,32 +8,52 @@ import ( ) func TestResolvedProblem(t *testing.T) { - oldDir, oldRuntime := ResolvedDir, ResolvedRuntimeDir - ResolvedDir, ResolvedRuntimeDir = t.TempDir(), filepath.Join(t.TempDir(), "missing") - t.Cleanup(func() { ResolvedDir, ResolvedRuntimeDir = oldDir, oldRuntime }) + oldUnit, oldRuntime := UnitDir, ResolvedRuntimeDir + UnitDir, ResolvedRuntimeDir = t.TempDir(), filepath.Join(t.TempDir(), "missing") + t.Cleanup(func() { UnitDir, ResolvedRuntimeDir = oldUnit, oldRuntime }) if p := ResolvedProblem("test", 53535); !strings.Contains(p, "systemd-resolved isn't running") || !strings.Contains(p, "port 53535") { t.Fatalf("without systemd-resolved: %q", p) } ResolvedRuntimeDir = t.TempDir() - if p := ResolvedProblem("test", 53535); !strings.Contains(p, "missing") || !strings.Contains(p, "flock install") { - t.Fatalf("missing drop-in: %q", p) + if p := ResolvedProblem("test", 53535); !strings.Contains(p, "missing") || !strings.Contains(p, "flock-php install") { + t.Fatalf("missing unit: %q", p) } - if err := os.WriteFile(ResolvedFile("test"), []byte(ResolvedContent("test", 53535)), 0o644); err != nil { + if err := os.WriteFile(ResolvedFile(), []byte(ResolvedContent("test", 53535)), 0o644); err != nil { t.Fatal(err) } if p := ResolvedProblem("test", 53535); p != "" { - t.Fatalf("current drop-in: %q", p) + t.Fatalf("current unit: %q", p) } - if p := ResolvedProblem("test", 5300); !strings.Contains(p, "port 5300") || !strings.Contains(p, "flock install") { + if p := ResolvedProblem("test", 5300); !strings.Contains(p, "port 5300") || !strings.Contains(p, "flock-php install") { t.Fatalf("other port: %q", p) } - if got := ResolvedFile("test"); got != filepath.Join(ResolvedDir, "flock-test.conf") { + if got := ResolvedFile(); got != filepath.Join(UnitDir, "flock-dns.service") { t.Fatalf("file: %q", got) } - if got := ResolvedContent("test", 53535); got != "# Managed by Flock\n[Resolve]\nDNS=127.0.0.1:53535\nDomains=~test\n" { - t.Fatalf("content changed (must match what flock install writes): %q", got) +} + +// The TLD goes to Flock through a link of its own, never through resolved's global servers. +func TestResolvedContentRoutesTheTLDThroughFlocksLink(t *testing.T) { + unit := ResolvedContent("test", 53535) + for _, want := range []string{ + "ExecStart=ip link add flock0 type dummy", + "ExecStart=ip address add 198.18.53.53/32 dev flock0", + `resolvectl dns flock0 127.0.0.1:53535 && resolvectl domain flock0 "~test"`, + "ExecStop=ip link delete flock0", + "WantedBy=multi-user.target", + } { + if !strings.Contains(unit, want) { + t.Errorf("missing %q in:\n%s", want, unit) + } + } + // A literal % would be a systemd specifier. + if strings.Contains(unit, "%") || strings.Contains(unit, "[Resolve]") { + t.Errorf("unit:\n%s", unit) + } + if got := LegacyDropIn("test"); got != filepath.Join(ResolvedDir, "flock-test.conf") { + t.Errorf("legacy drop-in: %q", got) } } From 18d4050eeab53de9037d2c97a8409e204ff5cc34 Mon Sep 17 00:00:00 2001 From: Sinan Eldem Date: Mon, 19 Oct 2026 11:40:54 +0300 Subject: [PATCH 11/26] fix(dns): keep NetworkManager off flock0, and report the link when it drifts --- internal/cli/install_linux.go | 15 ++++++++++++++ internal/dns/resolved.go | 37 ++++++++++++++++++++++++++++++++++- internal/dns/resolved_test.go | 24 ++++++++++++++++++++--- 3 files changed, 72 insertions(+), 4 deletions(-) diff --git a/internal/cli/install_linux.go b/internal/cli/install_linux.go index 7b05e1c5..ce8e016a 100644 --- a/internal/cli/install_linux.go +++ b/internal/cli/install_linux.go @@ -464,6 +464,13 @@ func newPrivilegedSetupCommand() *cobra.Command { _ = os.Remove(dns.ResolvedFile()) _ = runTool("systemctl", "daemon-reload") } + if data, _ := os.ReadFile(dns.NMFile()); string(data) == dns.NMContent { + _ = os.Remove(dns.NMFile()) + // Install created them when NetworkManager wasn't there; with it, they aren't empty. + _ = os.Remove(dns.NMConfDir) + _ = os.Remove(filepath.Dir(dns.NMConfDir)) + _ = runTool("systemctl", "try-reload-or-restart", "NetworkManager.service") + } if anchor, update, err := caAnchor(); err == nil { if _, err := os.Stat(anchor); err == nil { _ = os.Remove(anchor) @@ -478,6 +485,14 @@ func newPrivilegedSetupCommand() *cobra.Command { return fmt.Errorf("--dns-port needs --tld") } removeLegacyDropIn(tld) + // NetworkManager must leave the link alone before the unit creates it again. + if err := os.MkdirAll(dns.NMConfDir, 0o755); err != nil { + return err + } + if err := os.WriteFile(dns.NMFile(), []byte(dns.NMContent), 0o644); err != nil { + return err + } + _ = runTool("systemctl", "try-reload-or-restart", "NetworkManager.service") // fails without NetworkManager if err := os.WriteFile(dns.ResolvedFile(), []byte(dns.ResolvedContent(tld, dnsPort)), 0o644); err != nil { return err } diff --git a/internal/dns/resolved.go b/internal/dns/resolved.go index 3e0f6f69..262a9a3c 100644 --- a/internal/dns/resolved.go +++ b/internal/dns/resolved.go @@ -3,6 +3,7 @@ package dns import ( "errors" "fmt" + "net" "os" "path/filepath" ) @@ -22,6 +23,10 @@ var ResolvedDir = "/etc/systemd/resolved.conf.d" // ResolvedRuntimeDir exists while systemd-resolved runs. Tests move it. var ResolvedRuntimeDir = "/run/systemd/resolve" +// NMConfDir is where NetworkManager reads drop-ins. `flock install` writes one even before +// NetworkManager is installed, so Link stays out of its hands. Tests move it. +var NMConfDir = "/etc/NetworkManager/conf.d" + const ( // Unit is the system unit that sets up Link. Unit = "flock-dns.service" @@ -35,6 +40,15 @@ const ( // ResolvedFile is the unit `flock install` writes on Linux to send the TLD to Flock. func ResolvedFile() string { return filepath.Join(UnitDir, Unit) } +// NMFile is the NetworkManager drop-in that keeps it off Link. +func NMFile() string { return filepath.Join(NMConfDir, "90-flock.conf") } + +// NMContent makes Link unmanaged. NetworkManager otherwise adopts it as an external device and +// flushes its address when networking goes down (nmcli networking off, suspend), after which +// systemd-resolved drops the link's scope and the TLD stops resolving. += adds to the devices +// other drop-ins leave unmanaged instead of replacing them. +const NMContent = "# Managed by Flock\n[keyfile]\nunmanaged-devices+=interface-name:" + Link + "\n" + // LegacyDropIn is the resolved.conf drop-in installs before Link wrote for tld. func LegacyDropIn(tld string) string { return filepath.Join(ResolvedDir, "flock-"+tld+".conf") } @@ -69,8 +83,23 @@ func ResolvedRunning() bool { return err == nil } +// linkUp reports whether Link exists with its address. Tests replace it. +var linkUp = func() bool { + iface, err := net.InterfaceByName(Link) + if err != nil { + return false + } + addrs, _ := iface.Addrs() + for _, a := range addrs { + if a.String() == linkAddress { + return true + } + } + return false +} + // ResolvedProblem explains why systemd-resolved does not send tld lookups to Flock's DNS server on -// port, or returns "" when the unit is in place. +// port, or returns "" when the unit, the NetworkManager drop-in and Link are in place. func ResolvedProblem(tld string, port int) string { if !ResolvedRunning() { return fmt.Sprintf("systemd-resolved isn't running, so Linux doesn't send .%s lookups to Flock — "+ @@ -86,5 +115,11 @@ func ResolvedProblem(tld string, port int) string { case string(data) != ResolvedContent(tld, port): return fmt.Sprintf("%s doesn't send .%s lookups to Flock's DNS server on port %d — run `flock-php install`", path, tld, port) } + if data, _ := os.ReadFile(NMFile()); string(data) != NMContent { + return fmt.Sprintf("%s is missing, so NetworkManager may take %s's address — run `flock-php install`", NMFile(), Link) + } + if !linkUp() { + return fmt.Sprintf("%s is down or lost its address, so systemd-resolved doesn't send .%s lookups to Flock — run `sudo systemctl restart %s`", Link, tld, Unit) + } return "" } diff --git a/internal/dns/resolved_test.go b/internal/dns/resolved_test.go index f9f80947..cbbe5057 100644 --- a/internal/dns/resolved_test.go +++ b/internal/dns/resolved_test.go @@ -8,9 +8,11 @@ import ( ) func TestResolvedProblem(t *testing.T) { - oldUnit, oldRuntime := UnitDir, ResolvedRuntimeDir - UnitDir, ResolvedRuntimeDir = t.TempDir(), filepath.Join(t.TempDir(), "missing") - t.Cleanup(func() { UnitDir, ResolvedRuntimeDir = oldUnit, oldRuntime }) + oldUnit, oldRuntime, oldNM, oldUp := UnitDir, ResolvedRuntimeDir, NMConfDir, linkUp + UnitDir, ResolvedRuntimeDir, NMConfDir = t.TempDir(), filepath.Join(t.TempDir(), "missing"), t.TempDir() + up := true + linkUp = func() bool { return up } + t.Cleanup(func() { UnitDir, ResolvedRuntimeDir, NMConfDir, linkUp = oldUnit, oldRuntime, oldNM, oldUp }) if p := ResolvedProblem("test", 53535); !strings.Contains(p, "systemd-resolved isn't running") || !strings.Contains(p, "port 53535") { t.Fatalf("without systemd-resolved: %q", p) @@ -24,9 +26,22 @@ func TestResolvedProblem(t *testing.T) { if err := os.WriteFile(ResolvedFile(), []byte(ResolvedContent("test", 53535)), 0o644); err != nil { t.Fatal(err) } + // Installs from before the NetworkManager drop-in run the setup again. + if p := ResolvedProblem("test", 53535); !strings.Contains(p, NMFile()) || !strings.Contains(p, "flock-php install") { + t.Fatalf("missing NetworkManager drop-in: %q", p) + } + if err := os.WriteFile(NMFile(), []byte(NMContent), 0o644); err != nil { + t.Fatal(err) + } if p := ResolvedProblem("test", 53535); p != "" { t.Fatalf("current unit: %q", p) } + // Something took the link down or flushed its address after setup. + up = false + if p := ResolvedProblem("test", 53535); !strings.Contains(p, "flock0 is down") || !strings.Contains(p, "systemctl restart flock-dns.service") { + t.Fatalf("link without its address: %q", p) + } + up = true if p := ResolvedProblem("test", 5300); !strings.Contains(p, "port 5300") || !strings.Contains(p, "flock-php install") { t.Fatalf("other port: %q", p) } @@ -53,6 +68,9 @@ func TestResolvedContentRoutesTheTLDThroughFlocksLink(t *testing.T) { if strings.Contains(unit, "%") || strings.Contains(unit, "[Resolve]") { t.Errorf("unit:\n%s", unit) } + if !strings.Contains(NMContent, "[keyfile]\nunmanaged-devices+=interface-name:flock0\n") { + t.Errorf("NetworkManager drop-in:\n%s", NMContent) + } if got := LegacyDropIn("test"); got != filepath.Join(ResolvedDir, "flock-test.conf") { t.Errorf("legacy drop-in: %q", got) } From 9d631ee91f619a62303656f1b2516165cc875421 Mon Sep 17 00:00:00 2001 From: Sinan Eldem Date: Mon, 19 Oct 2026 12:15:30 +0300 Subject: [PATCH 12/26] fix(dns): check flock0 and NetworkManager even while lookups work, sort the drop-in last --- internal/api/api.go | 3 +- internal/cli/install_linux.go | 13 +++++++++ internal/cli/status.go | 3 ++ internal/daemon/wildcard.go | 3 ++ internal/daemon/wildcard_test.go | 9 ++++++ internal/dns/resolved.go | 48 ++++++++++++++++++++++++++++---- internal/dns/resolved_test.go | 24 +++++++++++++--- 7 files changed, 92 insertions(+), 11 deletions(-) diff --git a/internal/api/api.go b/internal/api/api.go index 847795c4..32febda7 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -163,7 +163,8 @@ type DNSStatus struct { TLD string `json:"tld"` Addr string `json:"addr"` // the embedded DNS server, when it runs // Wildcard reports whether every name under the TLD resolves (any subdomain, at any depth). - // When it doesn't, WildcardProblem says why. On Windows (the DNS rule is missing or overridden + // When it doesn't, WildcardProblem says why. On Linux it can also be set while names resolve, + // for what will stop them (Flock's link lost its address, or NetworkManager manages it). On Windows (the DNS rule is missing or overridden // by a policy) the sites' domains and extra domains still resolve, through the hosts file; on // macOS (/etc/resolver/ missing or overridden) nothing under the TLD does. Wildcard bool `json:"wildcard"` diff --git a/internal/cli/install_linux.go b/internal/cli/install_linux.go index ce8e016a..410e124d 100644 --- a/internal/cli/install_linux.go +++ b/internal/cli/install_linux.go @@ -256,6 +256,9 @@ func ensurePrivileged(cmd *cobra.Command, s *state, bin string, ca *certs.CA) er cmd.Printf("! systemd-resolved isn't running: for *.%s, have your DNS resolver forward .%s to 127.0.0.1 port %d\n", tld, tld, dnsPort) case resolvesToFlock(tld): cmd.Printf("✓ *.%s resolves via Flock\n", tld) + if problem := dns.LinkProblem(tld); problem != "" { + cmd.Printf("! but %s\n", problem) + } default: cmd.Printf("! *.%s doesn't resolve via Flock yet: see `systemctl status %s` and `flock-php status`\n", tld, dns.Unit) } @@ -439,6 +442,14 @@ func removeLegacyDropIn(tld string) { } } +// removeLegacyNMFile removes the NetworkManager drop-in under its earlier name, which sorted before +// drop-ins that could replace its list. +func removeLegacyNMFile() { + if data, _ := os.ReadFile(dns.LegacyNMFile()); string(data) == dns.NMContent { + _ = os.Remove(dns.LegacyNMFile()) + } +} + func newPrivilegedSetupCommand() *cobra.Command { var tld, caPath, lowPortsBin string var dnsPort int @@ -464,6 +475,7 @@ func newPrivilegedSetupCommand() *cobra.Command { _ = os.Remove(dns.ResolvedFile()) _ = runTool("systemctl", "daemon-reload") } + removeLegacyNMFile() if data, _ := os.ReadFile(dns.NMFile()); string(data) == dns.NMContent { _ = os.Remove(dns.NMFile()) // Install created them when NetworkManager wasn't there; with it, they aren't empty. @@ -486,6 +498,7 @@ func newPrivilegedSetupCommand() *cobra.Command { } removeLegacyDropIn(tld) // NetworkManager must leave the link alone before the unit creates it again. + removeLegacyNMFile() if err := os.MkdirAll(dns.NMConfDir, 0o755); err != nil { return err } diff --git a/internal/cli/status.go b/internal/cli/status.go index 79aa4202..33a62ddb 100644 --- a/internal/cli/status.go +++ b/internal/cli/status.go @@ -32,6 +32,9 @@ func newStatusCommand() *cobra.Command { switch { case st.DNS.Wildcard: fmt.Fprintf(out, "dns *.%s → 127.0.0.1, any subdomain (server on %s)\n", st.DNS.TLD, st.DNS.Addr) + if st.DNS.WildcardProblem != "" { + fmt.Fprintf(out, " ! %s\n", st.DNS.WildcardProblem) + } case st.DNS.TLD != "" && runtime.GOOS == "windows": fmt.Fprintf(out, "dns site domains and extra domains → 127.0.0.1 (hosts file); other *.%s subdomains don't resolve: %s\n", st.DNS.TLD, st.DNS.WildcardProblem) diff --git a/internal/daemon/wildcard.go b/internal/daemon/wildcard.go index 059dd331..06d7a578 100644 --- a/internal/daemon/wildcard.go +++ b/internal/daemon/wildcard.go @@ -68,6 +68,9 @@ func (d *daemon) checkWildcard() { state.ok = state.problem == "" case reaches(srv, cfg.TLD): state.ok = true + if runtime.GOOS == "linux" { + state.problem = dns.LinkProblem(cfg.TLD) // works now, but won't for long + } case runtime.GOOS == "windows": state.problem = windowsWildcardProblem(cfg.TLD) default: diff --git a/internal/daemon/wildcard_test.go b/internal/daemon/wildcard_test.go index bebac469..cf2567ab 100644 --- a/internal/daemon/wildcard_test.go +++ b/internal/daemon/wildcard_test.go @@ -123,6 +123,15 @@ func TestWildcardCheckReportsWhyItDoesNotWork(t *testing.T) { // Linux: they do once systemd-resolved (or another resolver) sends them to Flock. arrives.Store(true) reloadUntil(t, client, func(st api.Status) bool { return st.DNS.Wildcard && st.DNS.WildcardProblem == "" }) + + // With Flock's unit installed but its link gone, they still arrive until resolved restarts. + if err := os.WriteFile(dns.ResolvedFile(), []byte(dns.ResolvedContent(cfg.TLD, cfg.Ports.DNS)), 0o644); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { os.Remove(dns.ResolvedFile()) }) + reloadUntil(t, client, func(st api.Status) bool { + return st.DNS.Wildcard && strings.Contains(st.DNS.WildcardProblem, "flock0 is down") + }) return } diff --git a/internal/dns/resolved.go b/internal/dns/resolved.go index 262a9a3c..480cb2d5 100644 --- a/internal/dns/resolved.go +++ b/internal/dns/resolved.go @@ -5,7 +5,9 @@ import ( "fmt" "net" "os" + "os/exec" "path/filepath" + "strings" ) // systemd-resolved sends a domain's lookups to the DNS servers of the link that claims it as a @@ -40,8 +42,13 @@ const ( // ResolvedFile is the unit `flock install` writes on Linux to send the TLD to Flock. func ResolvedFile() string { return filepath.Join(UnitDir, Unit) } -// NMFile is the NetworkManager drop-in that keeps it off Link. -func NMFile() string { return filepath.Join(NMConfDir, "90-flock.conf") } +// NMFile is the NetworkManager drop-in that keeps it off Link. NetworkManager reads drop-ins in +// name order and a later unmanaged-devices= replaces the list, so the name sorts last; LinkProblem +// notices when another drop-in wins anyway. +func NMFile() string { return filepath.Join(NMConfDir, "zz-flock.conf") } + +// LegacyNMFile is the name the drop-in had before it sorted last. +func LegacyNMFile() string { return filepath.Join(NMConfDir, "90-flock.conf") } // NMContent makes Link unmanaged. NetworkManager otherwise adopts it as an external device and // flushes its address when networking goes down (nmcli networking off, suspend), after which @@ -98,6 +105,38 @@ var linkUp = func() bool { return false } +// nmManages reports whether NetworkManager manages Link. Without NetworkManager it doesn't. Tests +// replace it. +var nmManages = func() bool { + out, err := exec.Command("nmcli", "-t", "-f", "DEVICE,STATE", "device").Output() + if err != nil { + return false + } + for _, line := range strings.Split(string(out), "\n") { + if device, state, _ := strings.Cut(line, ":"); device == Link { + return state != "unmanaged" + } + } + return false +} + +// LinkProblem explains what will stop Link from sending tld lookups to Flock, even while they still +// arrive: systemd-resolved keeps a link's scope after it lost its address until resolved restarts. +// It returns "" when the link is fine, or isn't Flock's way of resolving the TLD on this machine. +func LinkProblem(tld string) string { + if data, _ := os.ReadFile(ResolvedFile()); !strings.HasPrefix(string(data), "# Managed by Flock") { + return "" + } + if !linkUp() { + return fmt.Sprintf("%s is down or lost its address, so systemd-resolved stops sending .%s lookups to Flock (at the latest when it restarts) — run `sudo systemctl restart %s`", Link, tld, Unit) + } + if nmManages() { + return fmt.Sprintf("NetworkManager manages %s, so it takes the link's address when networking goes down — "+ + "a drop-in read after %s probably sets unmanaged-devices= without it (see `NetworkManager --print-config`)", Link, NMFile()) + } + return "" +} + // ResolvedProblem explains why systemd-resolved does not send tld lookups to Flock's DNS server on // port, or returns "" when the unit, the NetworkManager drop-in and Link are in place. func ResolvedProblem(tld string, port int) string { @@ -118,8 +157,5 @@ func ResolvedProblem(tld string, port int) string { if data, _ := os.ReadFile(NMFile()); string(data) != NMContent { return fmt.Sprintf("%s is missing, so NetworkManager may take %s's address — run `flock-php install`", NMFile(), Link) } - if !linkUp() { - return fmt.Sprintf("%s is down or lost its address, so systemd-resolved doesn't send .%s lookups to Flock — run `sudo systemctl restart %s`", Link, tld, Unit) - } - return "" + return LinkProblem(tld) } diff --git a/internal/dns/resolved_test.go b/internal/dns/resolved_test.go index cbbe5057..cb7e5e0f 100644 --- a/internal/dns/resolved_test.go +++ b/internal/dns/resolved_test.go @@ -8,11 +8,21 @@ import ( ) func TestResolvedProblem(t *testing.T) { - oldUnit, oldRuntime, oldNM, oldUp := UnitDir, ResolvedRuntimeDir, NMConfDir, linkUp + oldUnit, oldRuntime, oldNM, oldUp, oldManages := UnitDir, ResolvedRuntimeDir, NMConfDir, linkUp, nmManages UnitDir, ResolvedRuntimeDir, NMConfDir = t.TempDir(), filepath.Join(t.TempDir(), "missing"), t.TempDir() - up := true + up, managed := true, false linkUp = func() bool { return up } - t.Cleanup(func() { UnitDir, ResolvedRuntimeDir, NMConfDir, linkUp = oldUnit, oldRuntime, oldNM, oldUp }) + nmManages = func() bool { return managed } + t.Cleanup(func() { + UnitDir, ResolvedRuntimeDir, NMConfDir, linkUp, nmManages = oldUnit, oldRuntime, oldNM, oldUp, oldManages + }) + + // Without Flock's unit the link isn't how the TLD resolves here. + up = false + if p := LinkProblem("test"); p != "" { + t.Fatalf("no unit: %q", p) + } + up = true if p := ResolvedProblem("test", 53535); !strings.Contains(p, "systemd-resolved isn't running") || !strings.Contains(p, "port 53535") { t.Fatalf("without systemd-resolved: %q", p) @@ -38,10 +48,16 @@ func TestResolvedProblem(t *testing.T) { } // Something took the link down or flushed its address after setup. up = false - if p := ResolvedProblem("test", 53535); !strings.Contains(p, "flock0 is down") || !strings.Contains(p, "systemctl restart flock-dns.service") { + if p := ResolvedProblem("test", 53535); !strings.Contains(p, "flock0 is down") || !strings.Contains(p, "systemctl restart flock-dns.service") || p != LinkProblem("test") { t.Fatalf("link without its address: %q", p) } up = true + // A drop-in read after Flock's replaced its unmanaged-devices list. + managed = true + if p := LinkProblem("test"); !strings.Contains(p, "NetworkManager manages flock0") || !strings.Contains(p, "zz-flock.conf") { + t.Fatalf("managed by NetworkManager: %q", p) + } + managed = false if p := ResolvedProblem("test", 5300); !strings.Contains(p, "port 5300") || !strings.Contains(p, "flock-php install") { t.Fatalf("other port: %q", p) } From 31d195791d29d5f7712f6a39fe5676b166b80659 Mon Sep 17 00:00:00 2001 From: nejcc Date: Mon, 19 Oct 2026 09:28:06 +0200 Subject: [PATCH 13/26] feat(linux): release archives of the Linux CLI, and install.sh that installs the newest one make linux builds flock--linux-{x64,arm64}.tar.gz with their .sha256; build.yml keeps them as artifacts and betas and releases require them. install.sh picks the newest release (the newest beta until there is a stable one), checks the archive and runs flock install. --- .github/workflows/beta.yml | 3 +- .github/workflows/build.yml | 29 ++++++++++++++-- .github/workflows/release.yml | 3 +- Makefile | 15 +++++++- install.sh | 65 +++++++++++++++++++++++++++++++++++ 5 files changed, 110 insertions(+), 5 deletions(-) create mode 100755 install.sh diff --git a/.github/workflows/beta.yml b/.github/workflows/beta.yml index fef0ea8b..c04f5669 100644 --- a/.github/workflows/beta.yml +++ b/.github/workflows/beta.yml @@ -100,7 +100,8 @@ jobs: - name: Release run: | go run ./tools/release notes > "$RUNNER_TEMP/notes.md" - for f in "dist/Flock-$VERSION-macOS.dmg" "dist/Flock-Setup-$VERSION-x64.exe"; do + for f in "dist/Flock-$VERSION-macOS.dmg" "dist/Flock-Setup-$VERSION-x64.exe" \ + "dist/flock-$VERSION-linux-x64.tar.gz" "dist/flock-$VERSION-linux-arm64.tar.gz"; do [ -f "$f" ] || { echo "::error::$f missing"; exit 1; } done gh release create "v$VERSION" --repo "$GITHUB_REPOSITORY" --target "$SHA" --prerelease --latest=false \ diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 7fdae2cc..086571f4 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,5 +1,5 @@ -# Runs the Go tests, and builds the macOS disk image (signed and notarized) and the Windows installer -# (signed with Artifact Signing) and keeps them as run artifacts. Signing takes the secrets and +# Runs the Go tests, and builds the macOS disk image (signed and notarized), the Windows installer +# (signed with Artifact Signing) and the Linux CLI archives, and keeps them as run artifacts. Signing takes the secrets and # variables of the "release" environment, and is required in the merge queue and for releases. # # - on every pull request: the Go tests only; the builds are skipped (which counts as passed) @@ -274,3 +274,28 @@ jobs: name: flock-windows path: windows/dist/Flock-Setup-*-x64.exe if-no-files-found: error + + linux: + name: Linux (x64, arm64) + needs: version + if: github.event_name != 'pull_request' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + ref: ${{ inputs.ref || github.event.pull_request.head.sha }} + + - uses: actions/setup-go@v7 + with: + go-version-file: go.mod + + - name: Build archives + run: make linux VERSION=${{ needs.version.outputs.version }} + + - uses: actions/upload-artifact@v7 + with: + name: flock-linux + path: | + dist/flock-*-linux-*.tar.gz + dist/flock-*-linux-*.tar.gz.sha256 + if-no-files-found: error diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d71db055..a3806980 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -128,7 +128,8 @@ jobs: VERSION: ${{ needs.release-please.outputs.version }} run: | ls dist - for f in "dist/Flock-$VERSION-macOS.dmg" "dist/Flock-Setup-$VERSION-x64.exe"; do + for f in "dist/Flock-$VERSION-macOS.dmg" "dist/Flock-Setup-$VERSION-x64.exe" \ + "dist/flock-$VERSION-linux-x64.tar.gz" "dist/flock-$VERSION-linux-arm64.tar.gz"; do [ -f "$f" ] || { echo "::error::$f missing"; exit 1; } done gh release upload "$TAG" dist/* --clobber --repo "$GITHUB_REPOSITORY" diff --git a/Makefile b/Makefile index 5d682bb2..00c54d4f 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev) LDFLAGS := -s -w -X flock/internal/cli.Version=$(VERSION) -.PHONY: build build-all test lint notices app dmg windows windows-test +.PHONY: build build-all test lint notices app dmg windows windows-test linux build: go build -ldflags "$(LDFLAGS)" -o bin/flock ./cmd/flock @@ -35,6 +35,19 @@ windows: GOOS=windows GOARCH=amd64 go build -ldflags "-s -w -X main.version=$(VERSION)" -o dist/windows-amd64/flock-helper.exe ./cmd/flock-helper GOOS=windows GOARCH=amd64 go build -ldflags "-s -w -H windowsgui" -o dist/windows-amd64/flockd.exe ./cmd/flockd +# The Linux CLI as release archives (install.sh and the AUR package download these): +# dist/flock--linux-{x64,arm64}.tar.gz, each with its .sha256. +linux: + for a in x64:amd64 arm64:arm64; do \ + name=flock-$(VERSION)-linux-$${a%%:*}; \ + rm -rf dist/$$name && mkdir -p dist/$$name && \ + CGO_ENABLED=0 GOOS=linux GOARCH=$${a##*:} go build -trimpath -ldflags "$(LDFLAGS)" -o dist/$$name/flock ./cmd/flock && \ + cp LICENSE NOTICE THIRD_PARTY_NOTICES dist/$$name/ && \ + tar -C dist/$$name -czf dist/$$name.tar.gz flock LICENSE NOTICE THIRD_PARTY_NOTICES && \ + rm -r dist/$$name && \ + (cd dist && sha256sum $$name.tar.gz > $$name.tar.gz.sha256) || exit 1; \ + done + # The tray app's models and API client (windows/FlockKit) run on any OS; the app itself is built on Windows (windows/build.ps1). windows-test: dotnet test windows/FlockKit.Tests diff --git a/install.sh b/install.sh new file mode 100755 index 00000000..fa503c60 --- /dev/null +++ b/install.sh @@ -0,0 +1,65 @@ +#!/bin/sh +# Installs Flock on Linux (any distribution with glibc, x86-64 or arm64): downloads the flock CLI of +# the newest release, checks it against its SHA-256 and runs `flock install`, which copies it to +# ~/.flock/bin and sets Flock up (asks for your password once). +# +# curl -fsSL https://raw.githubusercontent.com/flock-php/flock/main/install.sh | sh +# +# FLOCK_VERSION=1.0.0 installs that release instead. Arguments go to `flock install`: +# +# curl -fsSL https://raw.githubusercontent.com/flock-php/flock/main/install.sh | sh -s -- --skip-privileged +# +# Running it again updates Flock. +set -eu + +repo=flock-php/flock + +die() { + echo "flock: $*" >&2 + exit 1 +} + +[ "$(uname -s)" = Linux ] || die "this script is for Linux; for macOS and Windows get the app from https://github.com/$repo/releases" + +case $(uname -m) in +x86_64 | amd64) arch=x64 ;; +aarch64 | arm64) arch=arm64 ;; +*) die "no Flock build for $(uname -m) (there are x86-64 and arm64 ones)" ;; +esac + +for tool in curl tar sha256sum; do + command -v "$tool" >/dev/null || die "$tool is needed (Ubuntu: sudo apt install $tool)" +done + +# The tag_name of the first release in a GitHub API response, without its v. +tag() { + sed -n 's/^ *"tag_name": *"v\{0,1\}\([^"]*\)".*/\1/p' | head -n 1 +} + +version=${FLOCK_VERSION:-} +if [ -z "$version" ]; then + # The newest stable release; until there is one, the newest beta. + version=$(curl -fsSL "https://api.github.com/repos/$repo/releases/latest" 2>/dev/null | tag) + [ -n "$version" ] || version=$(curl -fsSL "https://api.github.com/repos/$repo/releases?per_page=1" | tag) + [ -n "$version" ] || die "could not find a release of $repo" +fi +version=${version#v} + +name=flock-$version-linux-$arch.tar.gz +url=https://github.com/$repo/releases/download/v$version/$name + +tmp=$(mktemp -d) +trap 'rm -rf "$tmp"' EXIT + +echo "Downloading Flock $version ($arch)…" +curl -fsSL -o "$tmp/$name" "$url" || die "could not download $url" +curl -fsSL -o "$tmp/$name.sha256" "$url.sha256" || die "could not download $url.sha256" +(cd "$tmp" && sha256sum -c --status "$name.sha256") || die "$name doesn't match its SHA-256" +tar -xzf "$tmp/$name" -C "$tmp" flock + +# Piped into sh, stdin is this script: the password prompt and any question need the terminal. +if [ ! -t 0 ] && (: /dev/null; then + "$tmp/flock" install "$@" Date: Mon, 19 Oct 2026 09:28:06 +0200 Subject: [PATCH 14/26] feat(linux): AUR package flock-php-bin util-linux owns /usr/bin/flock, so the package installs /usr/lib/flock-php/flock with a /usr/bin/flock-php link; flock-php install sets Flock up for the user. --- packaging/aur/flock-php-bin/PKGBUILD | 31 +++++++++++++++++++ .../aur/flock-php-bin/flock-php-bin.install | 11 +++++++ 2 files changed, 42 insertions(+) create mode 100644 packaging/aur/flock-php-bin/PKGBUILD create mode 100644 packaging/aur/flock-php-bin/flock-php-bin.install diff --git a/packaging/aur/flock-php-bin/PKGBUILD b/packaging/aur/flock-php-bin/PKGBUILD new file mode 100644 index 00000000..9e2870a3 --- /dev/null +++ b/packaging/aur/flock-php-bin/PKGBUILD @@ -0,0 +1,31 @@ +# Flock's Linux CLI from the release archives. util-linux owns /usr/bin/flock, so the binary lives in +# /usr/lib/flock-php with /usr/bin/flock-php pointing at it; `flock-php install` copies it to +# ~/.flock/bin, where the user's shell finds it as flock. +# +# For a new release: set _tag, then `updpkgsums && makepkg --printsrcinfo > .SRCINFO`, and push this +# directory to ssh://aur@aur.archlinux.org/flock-php-bin.git. + +pkgname=flock-php-bin +_tag=1.0.0-beta.10 +pkgver=${_tag/-/} +pkgrel=1 +pkgdesc='Local PHP development: sites on .test with HTTPS, PHP versions, databases, mail and dumps' +arch=(x86_64 aarch64) +url='https://github.com/flock-php/flock' +license=(AGPL-3.0-only) +optdepends=('nss: trust Flock'"'"'s certificate authority in Chrome and Firefox (certutil)') +provides=(flock-php) +conflicts=(flock-php) +install=flock-php-bin.install +options=(!strip !debug) +source_x86_64=("$url/releases/download/v$_tag/flock-$_tag-linux-x64.tar.gz") +source_aarch64=("$url/releases/download/v$_tag/flock-$_tag-linux-arm64.tar.gz") +sha256sums_x86_64=(SKIP) +sha256sums_aarch64=(SKIP) + +package() { + install -Dm755 flock "$pkgdir/usr/lib/flock-php/flock" + install -dm755 "$pkgdir/usr/bin" + ln -s /usr/lib/flock-php/flock "$pkgdir/usr/bin/flock-php" + install -Dm644 -t "$pkgdir/usr/share/licenses/$pkgname" LICENSE NOTICE THIRD_PARTY_NOTICES +} diff --git a/packaging/aur/flock-php-bin/flock-php-bin.install b/packaging/aur/flock-php-bin/flock-php-bin.install new file mode 100644 index 00000000..b41aa38a --- /dev/null +++ b/packaging/aur/flock-php-bin/flock-php-bin.install @@ -0,0 +1,11 @@ +post_install() { + echo '==> Set Flock up for your user (not as root): flock-php install' +} + +post_upgrade() { + echo '==> Update your Flock to this version (not as root): flock-php install' +} + +pre_remove() { + echo '==> Undo Flock'"'"'s setup first if you haven'"'"'t (not as root): flock uninstall' +} From 89d0784b7cacbd53af187b418b920963593c4802 Mon Sep 17 00:00:00 2001 From: nejcc Date: Mon, 19 Oct 2026 10:22:36 +0200 Subject: [PATCH 15/26] fix(linux): ship the CLI as flock-php, as #12 now names it The archives hold flock-php, install.sh runs it, and the AUR package installs /usr/bin/flock-php directly. The comments name the right folder, ~/.config/Flock/bin. --- Makefile | 6 +++--- install.sh | 13 +++++++------ packaging/aur/flock-php-bin/PKGBUILD | 9 +++------ packaging/aur/flock-php-bin/flock-php-bin.install | 2 +- 4 files changed, 14 insertions(+), 16 deletions(-) diff --git a/Makefile b/Makefile index 00c54d4f..301bffb4 100644 --- a/Makefile +++ b/Makefile @@ -36,14 +36,14 @@ windows: GOOS=windows GOARCH=amd64 go build -ldflags "-s -w -H windowsgui" -o dist/windows-amd64/flockd.exe ./cmd/flockd # The Linux CLI as release archives (install.sh and the AUR package download these): -# dist/flock--linux-{x64,arm64}.tar.gz, each with its .sha256. +# dist/flock--linux-{x64,arm64}.tar.gz with flock-php (util-linux owns flock), each with its .sha256. linux: for a in x64:amd64 arm64:arm64; do \ name=flock-$(VERSION)-linux-$${a%%:*}; \ rm -rf dist/$$name && mkdir -p dist/$$name && \ - CGO_ENABLED=0 GOOS=linux GOARCH=$${a##*:} go build -trimpath -ldflags "$(LDFLAGS)" -o dist/$$name/flock ./cmd/flock && \ + CGO_ENABLED=0 GOOS=linux GOARCH=$${a##*:} go build -trimpath -ldflags "$(LDFLAGS)" -o dist/$$name/flock-php ./cmd/flock && \ cp LICENSE NOTICE THIRD_PARTY_NOTICES dist/$$name/ && \ - tar -C dist/$$name -czf dist/$$name.tar.gz flock LICENSE NOTICE THIRD_PARTY_NOTICES && \ + tar -C dist/$$name -czf dist/$$name.tar.gz flock-php LICENSE NOTICE THIRD_PARTY_NOTICES && \ rm -r dist/$$name && \ (cd dist && sha256sum $$name.tar.gz > $$name.tar.gz.sha256) || exit 1; \ done diff --git a/install.sh b/install.sh index fa503c60..82d91bae 100755 --- a/install.sh +++ b/install.sh @@ -1,11 +1,12 @@ #!/bin/sh # Installs Flock on Linux (any distribution with glibc, x86-64 or arm64): downloads the flock CLI of -# the newest release, checks it against its SHA-256 and runs `flock install`, which copies it to -# ~/.flock/bin and sets Flock up (asks for your password once). +# the newest release, checks it against its SHA-256 and runs `flock-php install`, which copies it to +# ~/.config/Flock/bin and sets Flock up (asks for your password once). The CLI is flock-php on Linux: +# util-linux owns flock(1). # # curl -fsSL https://raw.githubusercontent.com/flock-php/flock/main/install.sh | sh # -# FLOCK_VERSION=1.0.0 installs that release instead. Arguments go to `flock install`: +# FLOCK_VERSION=1.0.0 installs that release instead. Arguments go to `flock-php install`: # # curl -fsSL https://raw.githubusercontent.com/flock-php/flock/main/install.sh | sh -s -- --skip-privileged # @@ -55,11 +56,11 @@ echo "Downloading Flock $version ($arch)…" curl -fsSL -o "$tmp/$name" "$url" || die "could not download $url" curl -fsSL -o "$tmp/$name.sha256" "$url.sha256" || die "could not download $url.sha256" (cd "$tmp" && sha256sum -c --status "$name.sha256") || die "$name doesn't match its SHA-256" -tar -xzf "$tmp/$name" -C "$tmp" flock +tar -xzf "$tmp/$name" -C "$tmp" flock-php # Piped into sh, stdin is this script: the password prompt and any question need the terminal. if [ ! -t 0 ] && (: /dev/null; then - "$tmp/flock" install "$@" .SRCINFO`, and push this # directory to ssh://aur@aur.archlinux.org/flock-php-bin.git. @@ -24,8 +23,6 @@ sha256sums_x86_64=(SKIP) sha256sums_aarch64=(SKIP) package() { - install -Dm755 flock "$pkgdir/usr/lib/flock-php/flock" - install -dm755 "$pkgdir/usr/bin" - ln -s /usr/lib/flock-php/flock "$pkgdir/usr/bin/flock-php" + install -Dm755 flock-php "$pkgdir/usr/bin/flock-php" install -Dm644 -t "$pkgdir/usr/share/licenses/$pkgname" LICENSE NOTICE THIRD_PARTY_NOTICES } diff --git a/packaging/aur/flock-php-bin/flock-php-bin.install b/packaging/aur/flock-php-bin/flock-php-bin.install index b41aa38a..7b2fd60e 100644 --- a/packaging/aur/flock-php-bin/flock-php-bin.install +++ b/packaging/aur/flock-php-bin/flock-php-bin.install @@ -7,5 +7,5 @@ post_upgrade() { } pre_remove() { - echo '==> Undo Flock'"'"'s setup first if you haven'"'"'t (not as root): flock uninstall' + echo '==> Undo Flock'"'"'s setup first if you haven'"'"'t (not as root): flock-php uninstall' } From a8ee4723bf0ad1a8b67d991d2b809fe62ba93637 Mon Sep 17 00:00:00 2001 From: nejcc Date: Mon, 19 Oct 2026 10:06:44 +0200 Subject: [PATCH 16/26] feat(linux): a tray app for Linux, with the panel, Mail and Logs flock-app (linux/, a Go module of its own) is a Wails v3 app on GTK 3 and WebKitGTK. It shows Flock's state in the tray, a panel like the macOS and Windows apps' (services, sites, the Xdebug and dump toggles), and the Mail and Logs windows. The pages are plain HTML; they reach the daemon's API through its unix socket, like the CLI. Mail HTML runs without scripts or remote loads. `flock-app mail` or `logs` opens that window, in the running app too. make linux-app builds it. --- Makefile | 7 +- linux/app/frontend/app.css | 101 +++++++++ linux/app/frontend/app.js | 63 ++++++ linux/app/frontend/icon.png | Bin 0 -> 15860 bytes linux/app/frontend/logs.html | 119 +++++++++++ linux/app/frontend/mail.html | 149 ++++++++++++++ linux/app/frontend/panel.html | 155 ++++++++++++++ linux/app/icons/AppIcon.png | Bin 0 -> 15860 bytes linux/app/icons/Tray.Dark.png | Bin 0 -> 4432 bytes linux/app/icons/TrayStopped.Dark.png | Bin 0 -> 4569 bytes linux/app/icons/TrayWarning.Dark.png | Bin 0 -> 5458 bytes linux/app/main.go | 298 +++++++++++++++++++++++++++ linux/go.mod | 24 +++ linux/go.sum | 35 ++++ 14 files changed, 950 insertions(+), 1 deletion(-) create mode 100644 linux/app/frontend/app.css create mode 100644 linux/app/frontend/app.js create mode 100644 linux/app/frontend/icon.png create mode 100644 linux/app/frontend/logs.html create mode 100644 linux/app/frontend/mail.html create mode 100644 linux/app/frontend/panel.html create mode 100644 linux/app/icons/AppIcon.png create mode 100644 linux/app/icons/Tray.Dark.png create mode 100644 linux/app/icons/TrayStopped.Dark.png create mode 100644 linux/app/icons/TrayWarning.Dark.png create mode 100644 linux/app/main.go create mode 100644 linux/go.mod create mode 100644 linux/go.sum diff --git a/Makefile b/Makefile index 301bffb4..d168f499 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev) LDFLAGS := -s -w -X flock/internal/cli.Version=$(VERSION) -.PHONY: build build-all test lint notices app dmg windows windows-test linux +.PHONY: build build-all test lint notices app dmg windows windows-test linux linux-app build: go build -ldflags "$(LDFLAGS)" -o bin/flock ./cmd/flock @@ -48,6 +48,11 @@ linux: (cd dist && sha256sum $$name.tar.gz > $$name.tar.gz.sha256) || exit 1; \ done +# The Linux tray app, bin/flock-app (linux/, a module of its own). Needs cgo, GTK 3 and WebKitGTK: +# Arch webkit2gtk-4.1, Ubuntu libwebkit2gtk-4.1-dev, Fedora webkit2gtk4.1-devel. +linux-app: + cd linux && go build -tags gtk3 -trimpath -ldflags "-s -w" -o ../bin/flock-app ./app + # The tray app's models and API client (windows/FlockKit) run on any OS; the app itself is built on Windows (windows/build.ps1). windows-test: dotnet test windows/FlockKit.Tests diff --git a/linux/app/frontend/app.css b/linux/app/frontend/app.css new file mode 100644 index 00000000..917222fd --- /dev/null +++ b/linux/app/frontend/app.css @@ -0,0 +1,101 @@ +:root { + --bg: #ffffff; + --bg-2: #f5f5f4; + --fg: #1c1917; + --muted: #78716c; + --line: rgba(0, 0, 0, 0.09); + --accent: #4f46e5; + --ok: #16a34a; + --warn: #d97706; + --bad: #dc2626; + --mono: ui-monospace, "JetBrains Mono", "Cascadia Code", "DejaVu Sans Mono", monospace; + color-scheme: light; +} +@media (prefers-color-scheme: dark) { + :root { + --bg: #1c1b1a; + --bg-2: #262524; + --fg: #f5f5f4; + --muted: #a8a29e; + --line: rgba(255, 255, 255, 0.09); + --accent: #818cf8; + color-scheme: dark; + } +} + +* { box-sizing: border-box; } +html, body { height: 100%; margin: 0; } +body { + background: var(--bg); + color: var(--fg); + font: 13px/1.45 system-ui, "Inter", "Cantarell", "Ubuntu", sans-serif; + -webkit-user-select: none; + user-select: none; + overflow: hidden; +} +.selectable, pre, input, textarea { -webkit-user-select: text; user-select: text; } + +button { + font: inherit; + color: inherit; + background: var(--bg-2); + border: 1px solid var(--line); + border-radius: 7px; + padding: 4px 11px; + cursor: pointer; +} +button:hover { border-color: var(--muted); } +button.primary { background: var(--accent); border-color: var(--accent); color: #fff; } +button.link { background: none; border: none; padding: 0; color: var(--accent); } +button:disabled { opacity: 0.5; cursor: default; } +input[type="search"], select { + font: inherit; + color: inherit; + background: var(--bg-2); + border: 1px solid var(--line); + border-radius: 7px; + padding: 4px 9px; +} + +.muted { color: var(--muted); } +.mono { font-family: var(--mono); font-size: 12px; } +.row { display: flex; align-items: center; gap: 8px; } +.grow { flex: 1; min-width: 0; } +.ellipsis { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.badge { + display: inline-block; + min-width: 18px; + padding: 0 6px; + border-radius: 9px; + background: var(--accent); + color: #fff; + font-size: 11px; + text-align: center; +} +.dot { width: 8px; height: 8px; border-radius: 50%; background: var(--muted); flex: none; } +.dot.ok { background: var(--ok); } +.dot.warn { background: var(--warn); } +.dot.bad { background: var(--bad); } +.error { + margin: 8px 12px; + padding: 8px 10px; + border-radius: 8px; + background: color-mix(in srgb, var(--bad) 12%, transparent); + color: var(--bad); + white-space: pre-wrap; +} +.empty { padding: 40px 20px; text-align: center; color: var(--muted); } + +/* Two panes with a list on the left (Mail, Logs). */ +.split { display: flex; height: 100vh; } +.split > aside { width: 340px; border-right: 1px solid var(--line); display: flex; flex-direction: column; } +.split > main { flex: 1; min-width: 0; display: flex; flex-direction: column; } +.toolbar { display: flex; align-items: center; gap: 8px; padding: 8px 12px; border-bottom: 1px solid var(--line); } +.scroll { flex: 1; overflow: auto; } +.list > div { padding: 8px 12px; border-bottom: 1px solid var(--line); cursor: default; } +.list > div:hover { background: var(--bg-2); } +.list > div.selected { background: color-mix(in srgb, var(--accent) 16%, transparent); } +.list h4 { margin: 10px 12px 4px; font-size: 11px; text-transform: uppercase; letter-spacing: 0.05em; color: var(--muted); } +.tabs { display: flex; gap: 2px; padding: 0 12px; border-bottom: 1px solid var(--line); } +.tabs button { border: none; border-bottom: 2px solid transparent; border-radius: 0; background: none; padding: 6px 10px; } +.tabs button.active { border-bottom-color: var(--accent); } diff --git a/linux/app/frontend/app.js b/linux/app/frontend/app.js new file mode 100644 index 00000000..2a800b3a --- /dev/null +++ b/linux/app/frontend/app.js @@ -0,0 +1,63 @@ +// Shared by the pages: the daemon's API (/api/…), the app's own actions (/app/…) and a small DOM +// builder that sets text, never HTML, so mail subjects and log lines can't inject markup. + +export async function api(path, { method = "GET", body } = {}) { + const res = await fetch("/api" + path, { + method, + headers: body ? { "Content-Type": "application/json" } : {}, + body: body ? JSON.stringify(body) : undefined, + }); + if (!res.ok) { + let message = res.statusText; + try { + message = (await res.json()).error || message; + } catch {} + throw new Error(message || `HTTP ${res.status}`); + } + if (res.status === 204) return null; + return (res.headers.get("Content-Type") || "").includes("json") ? res.json() : res.text(); +} + +export async function app(path) { + const res = await fetch("/app/" + path, { method: "POST" }); + if (!res.ok) throw new Error((await res.text()) || `HTTP ${res.status}`); +} + +export const openURL = (url) => app("open?url=" + encodeURIComponent(url)); +export const openWindow = (name) => app("window/" + name); + +// h("div", {class: "row", onclick: f}, "text", child, …) +export function h(tag, attrs = {}, ...children) { + const el = document.createElement(tag); + for (const [k, v] of Object.entries(attrs || {})) { + if (v == null || v === false) continue; + if (k.startsWith("on")) el.addEventListener(k.slice(2), v); + else el.setAttribute(k, v === true ? "" : v); + } + for (const c of children.flat()) { + if (c == null || c === false || c === "") continue; + el.append(c instanceof Node ? c : String(c)); + } + return el; +} + +export function ago(date) { + const s = (Date.now() - new Date(date)) / 1000; + if (s < 60) return "just now"; + if (s < 3600) return `${Math.floor(s / 60)} min ago`; + if (s < 86400) return `${Math.floor(s / 3600)} h ago`; + return new Date(date).toLocaleDateString(); +} + +export function size(bytes) { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`; + return `${(bytes / 1024 / 1024).toFixed(1)} MB`; +} + +// Runs fn now and every ms while the window is shown, like the apps' 2-second status poll. +export function poll(fn, ms = 2000) { + fn(); + setInterval(() => document.hidden || fn(), ms); + document.addEventListener("visibilitychange", () => document.hidden || fn()); +} diff --git a/linux/app/frontend/icon.png b/linux/app/frontend/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..0e33c719ca927ad73864c1de8c6ccfdcfe7cf462 GIT binary patch literal 15860 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
} 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 + + + +Flock Logs + + + + +
+ +
+
+ Select a log + + + + + +
+
+
+
+ + + + diff --git a/linux/app/frontend/mail.html b/linux/app/frontend/mail.html new file mode 100644 index 00000000..02d0ccaa --- /dev/null +++ b/linux/app/frontend/mail.html @@ -0,0 +1,149 @@ + + + + +Flock Mail + + + + +
+ +
Select a message
+
+ + + + diff --git a/linux/app/frontend/panel.html b/linux/app/frontend/panel.html new file mode 100644 index 00000000..9adf7c6f --- /dev/null +++ b/linux/app/frontend/panel.html @@ -0,0 +1,155 @@ + + + + +Flock + + + + +
+ +
+

Flock

+
+
+
+ +
+
+ + + +
+ + + + diff --git a/linux/app/icons/AppIcon.png b/linux/app/icons/AppIcon.png new file mode 100644 index 0000000000000000000000000000000000000000..0e33c719ca927ad73864c1de8c6ccfdcfe7cf462 GIT binary patch literal 15860 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
} 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 DC}Omp literal 0 HcmV?d00001 diff --git a/linux/app/icons/TrayWarning.Dark.png b/linux/app/icons/TrayWarning.Dark.png new file mode 100644 index 0000000000000000000000000000000000000000..daaf20c551a0abe1f579728ec864dd2b743ae7a4 GIT binary patch literal 5458 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 IM6N<$g2%*QzW@LL literal 0 HcmV?d00001 diff --git a/linux/app/main.go b/linux/app/main.go new file mode 100644 index 00000000..1d3dacf5 --- /dev/null +++ b/linux/app/main.go @@ -0,0 +1,298 @@ +// flock-app is Flock's tray app for Linux: the panel from the tray icon, and the Mail and Logs +// windows, like the macOS menu bar app and the Windows tray app. The pages are plain HTML in +// frontend/; they reach the daemon's API at /api/ (passed on to its unix socket, as the CLI does) +// and ask this program for what a page can't do itself at /app/. +package main + +import ( + "context" + "embed" + "errors" + "io" + "io/fs" + "log" + "net" + "net/http" + "net/http/httputil" + "net/url" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "github.com/wailsapp/wails/v3/pkg/application" + "github.com/wailsapp/wails/v3/pkg/events" + + "flock/internal/api" + "flock/internal/paths" +) + +//go:embed frontend +var frontend embed.FS + +//go:embed icons +var icons embed.FS + +func icon(name string) []byte { + data, _ := icons.ReadFile("icons/" + name + ".png") + return data +} + +// Windows the pages can open by name, with their size. +var windowSizes = map[string][2]int{ + "panel": {380, 560}, + "mail": {1100, 700}, + "logs": {1000, 650}, +} + +type flockApp struct { + app *application.App + root string + windows map[string]*application.WebviewWindow +} + +func main() { + root, err := paths.Root() + if err != nil { + log.Fatal(err) + } + f := &flockApp{root: root, windows: map[string]*application.WebviewWindow{}} + + f.app = application.New(application.Options{ + Name: "Flock", + Description: "Local PHP development", + Icon: icon("AppIcon"), + Assets: application.AssetOptions{Handler: f.handler()}, + // The tray keeps running when every window is closed. + Linux: application.LinuxOptions{ProgramName: "flock-app"}, + SingleInstance: &application.SingleInstanceOptions{ + UniqueID: "dev.flock.app", + // `flock-app mail` (e.g. from the .desktop file's actions) shows that window. + OnSecondInstanceLaunch: func(d application.SecondInstanceData) { + f.show(windowArg(d.Args)) + }, + }, + }) + + tray := f.app.SystemTray.New() + tray.SetIcon(icon("TrayStopped.Dark")) + tray.SetTooltip("Flock") + tray.SetLabel("Flock") + tray.OnClick(func() { f.toggle("panel") }) + + menu := f.app.NewMenu() + menu.Add("Open Flock").OnClick(func(*application.Context) { f.show("panel") }) + menu.Add("Mail").OnClick(func(*application.Context) { f.show("mail") }) + menu.Add("Logs").OnClick(func(*application.Context) { f.show("logs") }) + menu.AddSeparator() + menu.Add("Restart Flock").OnClick(func(*application.Context) { go f.restart() }) + menu.AddSeparator() + menu.Add("Quit").OnClick(func(*application.Context) { f.app.Quit() }) + tray.SetMenu(menu) + + go f.watch(tray) + + // Started at login (the autostart entry passes --background), it waits in the tray. + if !hasArg("--background") { + f.app.Event.OnApplicationEvent(events.Common.ApplicationStarted, func(*application.ApplicationEvent) { + f.show(windowArg(os.Args[1:])) + }) + } + if err := f.app.Run(); err != nil { + log.Fatal(err) + } +} + +// windowArg is the window named on the command line, or the panel. +func windowArg(args []string) string { + for _, a := range args { + if _, ok := windowSizes[a]; ok { + return a + } + } + return "panel" +} + +func hasArg(arg string) bool { + for _, a := range os.Args[1:] { + if a == arg { + return true + } + } + return false +} + +// window returns the named window, creating it hidden the first time. Closing a window hides it. +func (f *flockApp) window(name string) *application.WebviewWindow { + if w := f.windows[name]; w != nil { + return w + } + size := windowSizes[name] + w := f.app.Window.NewWithOptions(application.WebviewWindowOptions{ + Name: name, + Title: title(name), + Width: size[0], + Height: size[1], + URL: "/" + name + ".html", + Hidden: true, + // A fixed size makes tiling window managers (Hyprland, Sway) float the panel like a popup. + DisableResize: name == "panel", + }) + w.RegisterHook(events.Common.WindowClosing, func(e *application.WindowEvent) { + w.Hide() + e.Cancel() + }) + f.windows[name] = w + return w +} + +func title(name string) string { + if name == "panel" { + return "Flock" + } + return "Flock " + strings.ToUpper(name[:1]) + name[1:] +} + +func (f *flockApp) show(name string) { + w := f.window(name) + w.Show() + w.Focus() +} + +func (f *flockApp) toggle(name string) { + if w := f.window(name); w.IsVisible() { + w.Hide() + } else { + f.show(name) + } +} + +// watch keeps the tray icon and tooltip in step with the daemon, like the apps' status poll. +func (f *flockApp) watch(tray *application.SystemTray) { + client := api.NewClient(paths.Socket(f.root)) + last := "" + for { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + st, err := client.Status(ctx) + cancel() + + state, tip := "Tray", "Flock — running · "+st.Version + switch { + case err != nil: + state, tip = "TrayStopped", "Flock — not running" + case len(st.Warnings) > 0: + state, tip = "TrayWarning", "Flock — "+st.Warnings[0] + } + if state+tip != last { + last = state + tip + application.InvokeSync(func() { + tray.SetIcon(icon(state + ".Dark")) + tray.SetTooltip(tip) + // ponytail: Wails ignores the tooltip on Linux (its StatusNotifierItem ToolTip is + // fixed); the label is the item's Title, which some bars show instead. + tray.SetLabel(tip) + }) + } + time.Sleep(2 * time.Second) + } +} + +// flock is the installed CLI, which `flock install` copies into Flock's folder. +func (f *flockApp) flock() string { return filepath.Join(f.root, "bin", "flock") } + +func (f *flockApp) restart() error { + out, err := exec.Command(f.flock(), "restart").CombinedOutput() + if err != nil { + return errors.New(strings.TrimSpace(string(out))) + } + return nil +} + +func (f *flockApp) handler() http.Handler { + mux := http.NewServeMux() + + pages, _ := fs.Sub(frontend, "frontend") + mux.Handle("/", http.FileServerFS(pages)) + + // The daemon's API, through its unix socket (no token needed: only this user can reach it). + socket := paths.Socket(f.root) + transport := &http.Transport{ + DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { + return (&net.Dialer{}).DialContext(ctx, "unix", socket) + }, + } + daemon := &httputil.ReverseProxy{ + Rewrite: func(r *httputil.ProxyRequest) { + r.SetURL(&url.URL{Scheme: "http", Host: "flockd"}) + r.Out.URL.Path = strings.TrimPrefix(r.In.URL.Path, "/api") + }, + Transport: transport, + ErrorHandler: func(w http.ResponseWriter, _ *http.Request, err error) { + http.Error(w, `{"error":"Flock isn't running"}`, http.StatusBadGateway) + }, + } + mux.Handle("/api/", daemon) + + mux.HandleFunc("GET /app/installed", func(w http.ResponseWriter, _ *http.Request) { + if _, err := os.Stat(f.flock()); err != nil { + w.WriteHeader(http.StatusNotFound) + } + }) + mux.HandleFunc("POST /app/open", func(w http.ResponseWriter, r *http.Request) { + u := r.URL.Query().Get("url") + if !strings.HasPrefix(u, "http://") && !strings.HasPrefix(u, "https://") && !strings.HasPrefix(u, "mailto:") { + http.Error(w, "only web links open", http.StatusBadRequest) + return + } + _ = f.app.Browser.OpenURL(u) + }) + mux.HandleFunc("POST /app/folder", func(w http.ResponseWriter, r *http.Request) { + _ = exec.Command("xdg-open", r.URL.Query().Get("path")).Start() + }) + // A mail attachment, saved where the user picks. + mux.HandleFunc("POST /app/save-attachment", func(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + var dest string + var err error + application.InvokeSync(func() { + dest, err = f.app.Dialog.SaveFile().SetFilename(filepath.Base(q.Get("name"))).PromptForSingleSelection() + }) + if err != nil || dest == "" { + return + } + resp, err := (&http.Client{Transport: transport}).Get("http://flockd/v1/mail/" + + url.PathEscape(q.Get("id")) + "/attachments/" + url.PathEscape(q.Get("index"))) + if err == nil && resp.StatusCode != http.StatusOK { + err = errors.New(resp.Status) + } + if err == nil { + defer resp.Body.Close() + var out *os.File + if out, err = os.Create(dest); err == nil { + _, err = io.Copy(out, resp.Body) + if cerr := out.Close(); err == nil { + err = cerr + } + } + } + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } + }) + mux.HandleFunc("POST /app/window/{name}", func(w http.ResponseWriter, r *http.Request) { + name := r.PathValue("name") + if _, ok := windowSizes[name]; !ok { + http.NotFound(w, r) + return + } + application.InvokeSync(func() { f.show(name) }) + }) + mux.HandleFunc("POST /app/restart", func(w http.ResponseWriter, _ *http.Request) { + if err := f.restart(); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } + }) + mux.HandleFunc("POST /app/quit", func(http.ResponseWriter, *http.Request) { f.app.Quit() }) + return mux +} diff --git a/linux/go.mod b/linux/go.mod new file mode 100644 index 00000000..b5c06c70 --- /dev/null +++ b/linux/go.mod @@ -0,0 +1,24 @@ +// The Linux app, in a module of its own so its GUI dependencies (Wails, cgo with WebKitGTK) stay out +// of the CLI's module. It imports the CLI's packages through the replace below. +module flock/linux + +go 1.27.2 + +require ( + flock v0.0.0 + github.com/wailsapp/wails/v3 v3.0.0-beta.32 +) + +require ( + github.com/adrg/xdg v0.5.3 // indirect + github.com/coder/websocket v1.8.14 // indirect + github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect + github.com/emersion/go-smtp v0.25.0 // indirect + github.com/go-ole/go-ole v1.3.0 // indirect + github.com/godbus/dbus/v5 v5.2.2 // indirect + github.com/mattn/go-colorable v0.1.15 // indirect + github.com/mattn/go-isatty v0.0.24 // indirect + golang.org/x/sys v0.48.0 // indirect +) + +replace flock => ../ diff --git a/linux/go.sum b/linux/go.sum new file mode 100644 index 00000000..35d24391 --- /dev/null +++ b/linux/go.sum @@ -0,0 +1,35 @@ +github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78= +github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ= +github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= +github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 h1:oP4q0fw+fOSWn3DfFi4EXdT+B+gTtzx8GC9xsc26Znk= +github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ= +github.com/emersion/go-smtp v0.25.0 h1:krfiHrme2JbJYDh0DGuSRbvPpbnQTH/v9CIfPincl1I= +github.com/emersion/go-smtp v0.25.0/go.mod h1:ZtRRkbTyp2XTHCA+BmyTFTrj8xY4I+b4McvHxCU2gsQ= +github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e h1:Lf/gRkoycfOBPa42vU2bbgPurFong6zXeFtPoxholzU= +github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e/go.mod h1:uNVvRXArCGbZ508SxYYTC5v1JWoz2voff5pm25jU1Ok= +github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= +github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= +github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= +github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ= +github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= +github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY= +github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= +github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/wailsapp/wails/v3 v3.0.0-beta.32 h1:QU1Dv7EveSiVQxGgEAYY6AXxLj0QG1HwlroMJmK9isQ= +github.com/wailsapp/wails/v3 v3.0.0-beta.32/go.mod h1:Tk3i8pyehpIdV4aS9GfpgGjk7eOjuSpCrpyJXpS6y64= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= +golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= From 627903e9e57ab94b3ec273ee20de8590f34c21b0 Mon Sep 17 00:00:00 2001 From: nejcc Date: Mon, 19 Oct 2026 10:23:04 +0200 Subject: [PATCH 17/26] fix(linux): the app runs flock-php, the CLI's name on Linux --- linux/app/frontend/panel.html | 4 ++-- linux/app/main.go | 5 +++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/linux/app/frontend/panel.html b/linux/app/frontend/panel.html index 9adf7c6f..cffd2db3 100644 --- a/linux/app/frontend/panel.html +++ b/linux/app/frontend/panel.html @@ -101,7 +101,7 @@

Flock

h("section", {}, h("h3", {}, "Services"), ...services, ...procs.filter((p) => !p.up).map((p) => h("div", { class: "proc-error" }, `${p.name}: ${p.last_error || "not running"}`))), h("section", {}, h("h3", {}, `Sites (${sites.length})`), - sites.length ? h("div", { class: "sites" }, ...sites) : h("div", { class: "muted" }, "Run `flock park` in your projects folder, or `flock link` in a project.")), + sites.length ? h("div", { class: "sites" }, ...sites) : h("div", { class: "muted" }, "Run `flock-php park` in your projects folder, or `flock-php link` in a project.")), h("section", {}, toggle("Xdebug for requests that ask for it", st.xdebug, "xdebug"), toggle("Capture dump() and events", st.dumps, "dumps")), @@ -116,7 +116,7 @@

Flock

$("headline").textContent = "Not set up"; $("content").replaceChildren(h("section", {}, h("p", {}, "Flock isn't set up for your user yet. In a terminal, run:"), - h("pre", { class: "mono" }, "flock install"), + h("pre", { class: "mono" }, "flock-php install"), h("p", { class: "muted" }, "It asks for your password once, for .test DNS, the HTTPS certificate authority and ports 80 and 443."))); return; } diff --git a/linux/app/main.go b/linux/app/main.go index 1d3dacf5..9f9ec149 100644 --- a/linux/app/main.go +++ b/linux/app/main.go @@ -198,8 +198,9 @@ func (f *flockApp) watch(tray *application.SystemTray) { } } -// flock is the installed CLI, which `flock install` copies into Flock's folder. -func (f *flockApp) flock() string { return filepath.Join(f.root, "bin", "flock") } +// flock is the installed CLI, which `flock-php install` copies into Flock's folder. On Linux it is +// named flock-php: util-linux owns flock(1). +func (f *flockApp) flock() string { return filepath.Join(f.root, "bin", "flock-php") } func (f *flockApp) restart() error { out, err := exec.Command(f.flock(), "restart").CombinedOutput() From a33aa52ace374bcd955cc61edc968a2263eb4ecc Mon Sep 17 00:00:00 2001 From: nejcc Date: Mon, 19 Oct 2026 10:44:07 +0200 Subject: [PATCH 18/26] feat(linux): Settings in the Linux app, with the General and Sites panes General: the daemon (restart), opening at login (an XDG autostart entry), the beta channel, the network, the project folders (add with a folder picker, show, remove) and the data folder. Sites: the list and the selected site's details, as in the other apps: open, show its folder, remove, HTTPS, extra domains, the PHP and Node.js pickers (the project's pin or the default first, then the installed versions, which isolate the site), pinning into the project, and PHP-FPM or FrankenPHP (classic or Octane). The panel and the tray menu open Settings; `flock-app settings sites` opens a pane directly. --- linux/app/frontend/app.js | 6 ++ linux/app/frontend/panel.html | 4 +- linux/app/frontend/settings.html | 105 ++++++++++++++++++++ linux/app/frontend/settings/general.js | 63 ++++++++++++ linux/app/frontend/settings/sites.js | 127 +++++++++++++++++++++++++ linux/app/main.go | 86 ++++++++++++++--- 6 files changed, 379 insertions(+), 12 deletions(-) create mode 100644 linux/app/frontend/settings.html create mode 100644 linux/app/frontend/settings/general.js create mode 100644 linux/app/frontend/settings/sites.js diff --git a/linux/app/frontend/app.js b/linux/app/frontend/app.js index 2a800b3a..1f0e4b21 100644 --- a/linux/app/frontend/app.js +++ b/linux/app/frontend/app.js @@ -61,3 +61,9 @@ export function poll(fn, ms = 2000) { setInterval(() => document.hidden || fn(), ms); document.addEventListener("visibilitychange", () => document.hidden || fn()); } + +// A folder the user picks in the system's dialog, or "" when they cancel. +export async function pickFolder(title) { + const res = await fetch("/app/pick-folder?title=" + encodeURIComponent(title), { method: "POST" }); + return (await res.text()).trim(); +} diff --git a/linux/app/frontend/panel.html b/linux/app/frontend/panel.html index cffd2db3..9ab1954c 100644 --- a/linux/app/frontend/panel.html +++ b/linux/app/frontend/panel.html @@ -36,6 +36,7 @@

Flock

+
@@ -73,7 +74,7 @@

Flock

function render(st) { $("headline").textContent = st.warnings?.length ? "Needs attention" : "Running"; - $("subline").textContent = `v${st.version} · pid ${st.pid}`; + $("subline").textContent = `${/^\d/.test(st.version) ? "v" : ""}${st.version} · pid ${st.pid}`; const procs = st.processes || []; const dns = st.dns || {}; @@ -148,6 +149,7 @@

Flock

$("restart").onclick = () => restart($("restart"), "Restarting…"); $("quit").onclick = () => app("quit"); +$("settings").onclick = () => openWindow("settings"); poll(refresh); diff --git a/linux/app/frontend/settings.html b/linux/app/frontend/settings.html new file mode 100644 index 00000000..292109e9 --- /dev/null +++ b/linux/app/frontend/settings.html @@ -0,0 +1,105 @@ + + + + +Flock Settings + + + + +
+ +
+

+ +
+
+
+ + + + diff --git a/linux/app/frontend/settings/general.js b/linux/app/frontend/settings/general.js new file mode 100644 index 00000000..02a29cb1 --- /dev/null +++ b/linux/app/frontend/settings/general.js @@ -0,0 +1,63 @@ +// Settings → General: the daemon, opening at login, the update channel, the network and the +// project folders whose subfolders are served as sites. +import { api, app, ago, h, pickFolder } from "../app.js"; + +function field(label, ...content) { + return h("div", { class: "field" }, h("span", { class: "label" }, label), h("div", { class: "grow" }, ...content)); +} + +function checkbox(checked, onchange) { + const box = h("input", { type: "checkbox", checked }); + box.addEventListener("change", () => onchange(box.checked)); + return box; +} + +export default { + title: "General", + render(ctx) { + const st = ctx.status; + const cfg = ctx.config; + const dns = st.dns || {}; + + // Read when the pane is built; it only changes from here. + const login = checkbox(false, (on) => ctx.act(() => app("autostart?on=" + (on ? 1 : 0)))); + fetch("/app/autostart").then((r) => { login.checked = r.ok; }); + + const restart = h("button", { onclick: async () => { + restart.disabled = true; + restart.textContent = "Restarting…"; + await ctx.act(() => app("restart")); + } }, "Restart Flock"); + + const parked = cfg.parked || []; + return h("div", {}, + h("div", { class: "group" }, h("h3", {}, "Flock"), + field("Daemon", h("div", { class: "row" }, + h("span", { class: "grow" }, `Running · ${/^\d/.test(st.version) ? "v" : ""}${st.version} · pid ${st.pid} · started ${ago(st.started_at)}`), restart)), + field("Open at login", h("label", { class: "row" }, login, "Start the Flock app in the tray when you log in")), + field("Updates", h("label", { class: "row" }, + checkbox(st.channel === "beta", (on) => ctx.act(() => api("/v1/settings", { method: "POST", body: { channel: on ? "beta" : "stable" } }))), + "Get beta versions"), + h("div", { class: "hint" }, "On Linux, update with your package manager or install.sh; the channel also picks betas there."))), + + h("div", { class: "group" }, h("h3", {}, "Network"), + field("Domains", h("span", { class: "mono" }, `*.${cfg.tld}`), + h("div", { class: "hint" }, dns.wildcard ? `Every name under .${cfg.tld} resolves to Flock (DNS on ${dns.addr}).` : dns.wildcard_problem || "")), + field("Ports", h("span", { class: "mono" }, `http :${st.http_port} · https :${st.https_port} · smtp ${st.mail || "—"}`))), + + h("div", { class: "group" }, h("h3", {}, "Project folders"), + h("div", { class: "hint muted", style: "margin-bottom:6px" }, `Every folder inside a project folder is served as .${cfg.tld}.`), + ...parked.map((path) => h("div", { class: "field" }, + h("span", { class: "grow mono selectable ellipsis" }, path), + h("button", { onclick: () => app("folder?path=" + encodeURIComponent(path)) }, "Show"), + h("button", { onclick: () => ctx.act(() => api("/v1/parked", { method: "DELETE", body: { path } })) }, "Remove"))), + h("div", { style: "margin-top:8px" }, h("button", { onclick: async () => { + const path = await pickFolder("Add a project folder"); + if (path) ctx.act(() => api("/v1/parked", { method: "POST", body: { path } })); + } }, "Add Project Folder…"))), + + h("div", { class: "group" }, h("h3", {}, "Data"), + field("Folder", h("div", { class: "row" }, h("span", { class: "grow mono selectable ellipsis" }, cfg.root), + h("button", { onclick: () => app("folder?path=" + encodeURIComponent(cfg.root)) }, "Show"))))); + }, +}; diff --git a/linux/app/frontend/settings/sites.js b/linux/app/frontend/settings/sites.js new file mode 100644 index 00000000..216a5fce --- /dev/null +++ b/linux/app/frontend/settings/sites.js @@ -0,0 +1,127 @@ +// Settings → Sites: the sites in a list and the selected one's details, like the other apps. Each +// version picker offers the project's pin (or the default) first, then the installed versions, +// which isolate the site on this machine. +import { api, app, h, openURL, pickFolder } from "../app.js"; + +let selection = null; + +const site = (name) => `/v1/sites/${encodeURIComponent(name)}`; + +function field(label, ...content) { + return h("div", { class: "field" }, h("span", { class: "label" }, label), h("div", { class: "grow" }, ...content)); +} + +function versionPicker(ctx, s, tool, installed, fallback) { + const source = s[`${tool}_source`]; + const current = s[tool] || ""; + const pinned = s[`${tool}_pinned`]; + const file = s[`${tool}_file`]; + const name = tool === "php" ? "PHP" : "Node.js"; + + const follow = pinned && file ? `Project: ${pinned} (${file})` : `Default${fallback ? ` (${fallback})` : ""}`; + const versions = [...installed]; + if (source === "isolated" && current && !versions.includes(current)) versions.push(current); + + const select = h("select", {}, + h("option", { value: "" }, follow), + ...versions.map((v) => h("option", { value: v, selected: source === "isolated" && v === current }, `${name} ${v}`))); + select.addEventListener("change", () => ctx.act(() => api(site(s.name), { method: "PATCH", body: { [tool]: select.value } }))); + + const hint = { + isolated: `${name} ${current} is set for this site on this machine (isolated).`, + project: `${name} ${current} is pinned in ${file}, for everyone working on the project.`, + }[source] || (current ? `${name} ${current}, the default.` : `No ${name} version is installed yet.`); + + const actions = []; + if (current && source !== "project") { + actions.push(h("button", { onclick: () => ctx.act(() => api(`${site(s.name)}/pin`, { method: "POST", body: { tool, version: current } })) }, + `Pin ${current} in the project`)); + } + if (pinned && file) { + actions.push(h("button", { onclick: () => ctx.act(() => api(`${site(s.name)}/pin/${tool}`, { method: "DELETE" })) }, `Remove the pin from ${file}`)); + } + return field(name, h("div", { class: "row" }, select, ...actions), h("div", { class: "hint" }, hint)); +} + +function details(ctx, s) { + const cfg = ctx.config; + const domains = s.domains || []; + + const newDomain = h("input", { type: "text", placeholder: "tenant1 or app-2.test" }); + const addDomain = () => newDomain.value.trim() && + ctx.act(() => api(`${site(s.name)}/domains`, { method: "POST", body: { domain: newDomain.value.trim() } })); + newDomain.addEventListener("keydown", (e) => e.key === "Enter" && addDomain()); + + const secured = h("input", { type: "checkbox", checked: s.secured }); + secured.addEventListener("change", () => ctx.act(() => api(site(s.name), { method: "PATCH", body: { secured: secured.checked } }))); + + const server = h("select", {}, + h("option", { value: "fastcgi", selected: s.server !== "frankenphp" }, "PHP-FPM"), + h("option", { value: "frankenphp", selected: s.server === "frankenphp" }, "FrankenPHP")); + server.addEventListener("change", () => ctx.act(() => api(site(s.name), { method: "PATCH", body: { server: server.value } }))); + const worker = h("select", { disabled: s.server !== "frankenphp" || (!s.octane_available && s.worker !== "octane") }, + h("option", { value: "", selected: s.worker !== "octane" }, "Classic"), + h("option", { value: "octane", selected: s.worker === "octane" }, "Laravel Octane (worker mode)")); + worker.addEventListener("change", () => ctx.act(() => api(site(s.name), { method: "PATCH", body: { worker: worker.value } }))); + const fp = s.frankenphp; + + return h("div", { class: "grow", style: "overflow:auto; padding: 4px 20px 20px" }, + h("div", { class: "row", style: "margin: 8px 0" }, + h("div", { class: "grow" }, h("h3", { style: "margin:0" }, s.domain), h("div", { class: "muted mono selectable ellipsis" }, s.path)), + h("button", { onclick: () => openURL(s.url || `http://${s.domain}`) }, "Open"), + h("button", { onclick: () => app("folder?path=" + encodeURIComponent(s.path)) }, "Show Folder"), + !s.parked && h("button", { onclick: () => confirm(`Stop serving ${s.domain}? The folder stays as it is.`) && + ctx.act(async () => { await api(site(s.name), { method: "DELETE" }); selection = null; }) }, "Remove")), + s.problem && h("div", { class: "error", style: "margin: 0 0 8px" }, s.problem), + s.parked && h("div", { class: "hint muted" }, "Served because its folder is in a project folder (General). Changing a setting here adds it as a site of its own."), + + h("div", { class: "group" }, h("h3", {}, "Address"), + field("HTTPS", h("label", { class: "row" }, secured, "Serve over HTTPS with Flock's local certificate")), + field("Extra domains", + domains.length ? h("div", { class: "chips" }, ...domains.map((d) => h("span", { class: "chip", title: d.problem || d.url || "" }, + (d.problem ? "⚠ " : "") + d.host, + h("button", { title: "Remove", onclick: () => ctx.act(() => api(`${site(s.name)}/domains/${encodeURIComponent(d.entry)}`, { method: "DELETE" })) }, "×")))) : "", + h("div", { class: "row", style: "margin-top:6px" }, newDomain, h("button", { onclick: addDomain }, "Add")), + h("div", { class: "hint" }, `A subdomain (tenant1 → tenant1.${s.domain}) or a name of its own (app-2.${cfg.tld}).`))), + + h("div", { class: "group" }, h("h3", {}, "Versions"), + versionPicker(ctx, s, "php", (cfg.php || []).map((p) => p.version), cfg.default_php), + versionPicker(ctx, s, "node", cfg.node || [], cfg.default_node)), + + h("div", { class: "group" }, h("h3", {}, "Server"), + field("PHP runs on", h("div", { class: "row" }, server, worker), + s.server === "frankenphp" && !s.octane_available && h("div", { class: "hint" }, "Octane's worker mode needs laravel/octane in the project.")), + fp && field("FrankenPHP", + h("div", { class: "row" }, + h("span", { class: "grow" }, fp.problem || [fp.state, fp.version, fp.php_version && `PHP ${fp.php_version}`].filter(Boolean).join(" · ") + + (fp.progress > 0 && fp.progress < 1 ? ` (${Math.round(fp.progress * 100)}%)` : "")), + h("button", { onclick: () => ctx.act(() => api(`${site(s.name)}/restart`, { method: "POST" })) }, "Restart"))))); +} + +export default { + title: "Sites", + subtitle: "Sites you added one by one, plus every folder inside your project folders.", + flush: true, + render(ctx) { + const sites = [...(ctx.status.sites || [])].sort((a, b) => a.domain.localeCompare(b.domain)); + const add = h("button", { class: "primary", onclick: async () => { + const path = await pickFolder("Add a site"); + if (path) ctx.act(() => api("/v1/sites", { method: "POST", body: { path } })); + } }, "Add Site…"); + + if (!sites.length) { + return h("div", { class: "empty grow" }, h("p", {}, "No sites yet. Add one here, or add a project folder in General."), add); + } + if (!sites.some((s) => s.name === selection)) selection = sites[0].name; + const selected = sites.find((s) => s.name === selection); + + return h("div", { class: "row grow", style: "align-items: stretch; min-height: 0" }, + h("div", { style: "width: 260px; border-right: 1px solid var(--line); display: flex; flex-direction: column" }, + h("div", { class: "toolbar" }, add), + h("div", { class: "scroll list" }, ...sites.map((s) => + h("div", { class: s.name === selection ? "selected" : "", onclick: () => { selection = s.name; ctx.rerender(); } }, + h("div", { class: "row" }, h("span", { class: "dot " + (s.problem ? "warn" : "ok") }), h("span", { class: "grow ellipsis" }, (s.secured ? "🔒 " : "") + s.domain)), + h("div", { class: "muted mono", style: "margin-left: 16px" }, [s.php && `PHP ${s.php}`, s.server === "frankenphp" && "FrankenPHP", s.parked && "parked"].filter(Boolean).join(" · ")))))), + details(ctx, selected)); + }, +}; diff --git a/linux/app/main.go b/linux/app/main.go index 9f9ec149..715f83fd 100644 --- a/linux/app/main.go +++ b/linux/app/main.go @@ -18,6 +18,7 @@ import ( "os" "os/exec" "path/filepath" + "strconv" "strings" "time" @@ -41,9 +42,10 @@ func icon(name string) []byte { // Windows the pages can open by name, with their size. var windowSizes = map[string][2]int{ - "panel": {380, 560}, - "mail": {1100, 700}, - "logs": {1000, 650}, + "panel": {380, 560}, + "mail": {1100, 700}, + "logs": {1000, 650}, + "settings": {980, 680}, } type flockApp struct { @@ -70,7 +72,7 @@ func main() { UniqueID: "dev.flock.app", // `flock-app mail` (e.g. from the .desktop file's actions) shows that window. OnSecondInstanceLaunch: func(d application.SecondInstanceData) { - f.show(windowArg(d.Args)) + f.open(d.Args) }, }, }) @@ -85,6 +87,7 @@ func main() { menu.Add("Open Flock").OnClick(func(*application.Context) { f.show("panel") }) menu.Add("Mail").OnClick(func(*application.Context) { f.show("mail") }) menu.Add("Logs").OnClick(func(*application.Context) { f.show("logs") }) + menu.Add("Settings…").OnClick(func(*application.Context) { f.show("settings") }) menu.AddSeparator() menu.Add("Restart Flock").OnClick(func(*application.Context) { go f.restart() }) menu.AddSeparator() @@ -96,7 +99,7 @@ func main() { // Started at login (the autostart entry passes --background), it waits in the tray. if !hasArg("--background") { f.app.Event.OnApplicationEvent(events.Common.ApplicationStarted, func(*application.ApplicationEvent) { - f.show(windowArg(os.Args[1:])) + f.open(os.Args[1:]) }) } if err := f.app.Run(); err != nil { @@ -104,14 +107,20 @@ func main() { } } -// windowArg is the window named on the command line, or the panel. -func windowArg(args []string) string { - for _, a := range args { +// open shows the window named on the command line (the panel without one), and the part of it +// named after that, e.g. `flock-app settings sites`. +func (f *flockApp) open(args []string) { + for i, a := range args { if _, ok := windowSizes[a]; ok { - return a + section := "" + if i+1 < len(args) && !strings.HasPrefix(args[i+1], "-") { + section = args[i+1] + } + f.showSection(a, section) + return } } - return "panel" + f.show("panel") } func hasArg(arg string) bool { @@ -154,8 +163,19 @@ func title(name string) string { return "Flock " + strings.ToUpper(name[:1]) + name[1:] } -func (f *flockApp) show(name string) { +func (f *flockApp) show(name string) { f.showSection(name, "") } + +// showSection shows a window at a part of its page (its URL's fragment, e.g. a Settings pane). +func (f *flockApp) showSection(name, section string) { + created := f.windows[name] == nil w := f.window(name) + if section != "" { + if created { + w.SetURL("/" + name + ".html#" + url.PathEscape(section)) + } else { + w.ExecJS("location.hash = " + strconv.Quote(section)) + } + } w.Show() w.Focus() } @@ -281,6 +301,32 @@ func (f *flockApp) handler() http.Handler { http.Error(w, err.Error(), http.StatusInternalServerError) } }) + // A folder the user picks (Add Site, project folders); the response is its path, or empty. + mux.HandleFunc("POST /app/pick-folder", func(w http.ResponseWriter, r *http.Request) { + var dir string + application.InvokeSync(func() { + dir, _ = f.app.Dialog.OpenFile().CanChooseDirectories(true).CanChooseFiles(false). + SetTitle(r.URL.Query().Get("title")).PromptForSingleSelection() + }) + _, _ = w.Write([]byte(dir)) + }) + // Open at login: an XDG autostart entry that starts the app in the tray. + mux.HandleFunc("GET /app/autostart", func(w http.ResponseWriter, _ *http.Request) { + if _, err := os.Stat(autostartFile()); err != nil { + w.WriteHeader(http.StatusNotFound) + } + }) + mux.HandleFunc("POST /app/autostart", func(w http.ResponseWriter, r *http.Request) { + var err error + if r.URL.Query().Get("on") == "1" { + err = writeAutostart() + } else if err = os.Remove(autostartFile()); errors.Is(err, os.ErrNotExist) { + err = nil + } + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } + }) mux.HandleFunc("POST /app/window/{name}", func(w http.ResponseWriter, r *http.Request) { name := r.PathValue("name") if _, ok := windowSizes[name]; !ok { @@ -297,3 +343,21 @@ func (f *flockApp) handler() http.Handler { mux.HandleFunc("POST /app/quit", func(http.ResponseWriter, *http.Request) { f.app.Quit() }) return mux } + +func autostartFile() string { + dir, _ := os.UserConfigDir() + return filepath.Join(dir, "autostart", "flock-app.desktop") +} + +func writeAutostart() error { + self, err := os.Executable() + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(autostartFile()), 0o755); err != nil { + return err + } + entry := "[Desktop Entry]\nType=Application\nName=Flock\nComment=Local PHP development\n" + + "Exec=\"" + self + "\" --background\nIcon=flock-app\nX-GNOME-Autostart-enabled=true\n" + return os.WriteFile(autostartFile(), []byte(entry), 0o644) +} From 913a6cd00c7a9b75f198d1f06800d3a2a7f937a4 Mon Sep 17 00:00:00 2001 From: nejcc Date: Mon, 19 Oct 2026 10:50:07 +0200 Subject: [PATCH 19/26] feat(linux): the Linux app looks like flockphp.com The site's tokens (paper, surface, ink, signal; light and dark), Inter and JetBrains Mono shipped with the app (OFL, licenses in fonts/), switches for every toggle, and Tabler icons. Settings has the colored sidebar of the site's screenshots, and Sites is a table: address, PHP, Node.js, HTTPS and actions, with a row's pins, domains and server under the pin button. --- linux/app/frontend/app.css | 130 +++++++++--- linux/app/frontend/fonts/Inter-OFL.txt | 92 +++++++++ .../app/frontend/fonts/JetBrainsMono-OFL.txt | 93 +++++++++ linux/app/frontend/fonts/inter.woff2 | Bin 0 -> 48444 bytes linux/app/frontend/fonts/jetbrains-mono.woff2 | Bin 0 -> 40404 bytes linux/app/frontend/icons.js | 27 +++ linux/app/frontend/settings.html | 22 +- linux/app/frontend/settings/general.js | 2 + linux/app/frontend/settings/sites.js | 191 ++++++++++-------- 9 files changed, 428 insertions(+), 129 deletions(-) create mode 100644 linux/app/frontend/fonts/Inter-OFL.txt create mode 100644 linux/app/frontend/fonts/JetBrainsMono-OFL.txt create mode 100644 linux/app/frontend/fonts/inter.woff2 create mode 100644 linux/app/frontend/fonts/jetbrains-mono.woff2 create mode 100644 linux/app/frontend/icons.js diff --git a/linux/app/frontend/app.css b/linux/app/frontend/app.css index 917222fd..44494f82 100644 --- a/linux/app/frontend/app.css +++ b/linux/app/frontend/app.css @@ -1,24 +1,42 @@ +/* The look of flockphp.com: its paper/surface/ink/signal tokens, Inter and JetBrains Mono (both + OFL, in fonts/), light and dark following the desktop. */ +@font-face { font-family: "Inter"; src: url("fonts/inter.woff2") format("woff2"); font-weight: 100 900; font-display: swap; } +@font-face { font-family: "JetBrains Mono"; src: url("fonts/jetbrains-mono.woff2") format("woff2"); font-weight: 100 800; font-display: swap; } + :root { - --bg: #ffffff; - --bg-2: #f5f5f4; - --fg: #1c1917; - --muted: #78716c; - --line: rgba(0, 0, 0, 0.09); - --accent: #4f46e5; + --bg: #f2f9fa; /* paper */ + --bg-2: #ffffff; /* surface: cards, fields */ + --sidebar: #e7f2f4; + --fg: #0f172a; /* ink */ + --fg-soft: #334155; + --muted: #64748b; + --line: #0f172a1a; + --line-strong: #0f172a38; + --accent: #0d7a8c; /* signal */ + --accent-ink: #ffffff; + --link: #0369a1; --ok: #16a34a; - --warn: #d97706; + --warn: #b4530f; --bad: #dc2626; - --mono: ui-monospace, "JetBrains Mono", "Cascadia Code", "DejaVu Sans Mono", monospace; + --mono: "JetBrains Mono", "JetBrainsMono NF", ui-monospace, monospace; color-scheme: light; } @media (prefers-color-scheme: dark) { :root { - --bg: #1c1b1a; - --bg-2: #262524; - --fg: #f5f5f4; - --muted: #a8a29e; - --line: rgba(255, 255, 255, 0.09); - --accent: #818cf8; + --bg: #0f172a; + --bg-2: #131c2e; + --sidebar: #111a2c; + --fg: #e6f5f8; + --fg-soft: #cbd5e1; + --muted: #8b9bb0; + --line: #ffffff17; + --line-strong: #ffffff33; + --accent: #3cc9dc; + --accent-ink: #0f172a; + --link: #7dd3fc; + --ok: #4ade80; + --warn: #fb923c; + --bad: #f87171; color-scheme: dark; } } @@ -28,35 +46,82 @@ html, body { height: 100%; margin: 0; } body { background: var(--bg); color: var(--fg); - font: 13px/1.45 system-ui, "Inter", "Cantarell", "Ubuntu", sans-serif; + font: 13px/1.45 "Inter", system-ui, sans-serif; + font-feature-settings: "cv11", "ss01"; + -webkit-font-smoothing: antialiased; -webkit-user-select: none; user-select: none; overflow: hidden; } .selectable, pre, input, textarea { -webkit-user-select: text; user-select: text; } +h1, h2, h3 { font-weight: 650; letter-spacing: -0.01em; } +a, .link { color: var(--link); text-decoration: none; } button { font: inherit; - color: inherit; + font-weight: 500; + color: var(--fg); background: var(--bg-2); - border: 1px solid var(--line); - border-radius: 7px; - padding: 4px 11px; + border: 1px solid var(--line-strong); + border-radius: 8px; + padding: 5px 12px; cursor: pointer; } button:hover { border-color: var(--muted); } -button.primary { background: var(--accent); border-color: var(--accent); color: #fff; } -button.link { background: none; border: none; padding: 0; color: var(--accent); } +button.primary { background: var(--accent); border-color: var(--accent); color: var(--accent-ink); } +button.ghost { background: none; border-color: transparent; color: var(--muted); padding: 4px; display: inline-flex; } +button.ghost:hover { color: var(--fg); background: var(--line); } +button.link { background: none; border: none; padding: 0; color: var(--link); } button:disabled { opacity: 0.5; cursor: default; } -input[type="search"], select { + +input[type="search"], input[type="text"], select { font: inherit; - color: inherit; + color: var(--fg); background: var(--bg-2); - border: 1px solid var(--line); - border-radius: 7px; - padding: 4px 9px; + border: 1px solid var(--line-strong); + border-radius: 6px; + padding: 5px 9px; +} +select { + -webkit-appearance: none; + appearance: none; + padding-right: 28px; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 24 24' fill='none' stroke='%238b9bb0' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M6 9l6 6l6 -6'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 8px center; +} +input:focus-visible, select:focus-visible, button:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; } + +/* Every checkbox is a switch, like the site's app. */ +input[type="checkbox"] { + -webkit-appearance: none; + appearance: none; + flex: none; + width: 34px; + height: 20px; + margin: 0; + border-radius: 10px; + background: var(--line-strong); + position: relative; + cursor: pointer; + transition: background 0.15s; +} +input[type="checkbox"]::after { + content: ""; + position: absolute; + top: 2px; + left: 2px; + width: 16px; + height: 16px; + border-radius: 50%; + background: #fff; + box-shadow: 0 1px 2px #0003; + transition: transform 0.15s; } +input[type="checkbox"]:checked { background: var(--accent); } +input[type="checkbox"]:checked::after { transform: translateX(14px); } +.icon { flex: none; } .muted { color: var(--muted); } .mono { font-family: var(--mono); font-size: 12px; } .row { display: flex; align-items: center; gap: 8px; } @@ -68,10 +133,12 @@ input[type="search"], select { padding: 0 6px; border-radius: 9px; background: var(--accent); - color: #fff; + color: var(--accent-ink); font-size: 11px; + font-weight: 600; text-align: center; } +.tag { display: inline-block; padding: 0 7px; border-radius: 5px; background: var(--line); color: var(--fg-soft); font-size: 11px; font-weight: 500; } .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--muted); flex: none; } .dot.ok { background: var(--ok); } .dot.warn { background: var(--warn); } @@ -85,17 +152,18 @@ input[type="search"], select { white-space: pre-wrap; } .empty { padding: 40px 20px; text-align: center; color: var(--muted); } +.card { background: var(--bg-2); border: 1px solid var(--line); border-radius: 10px; } /* Two panes with a list on the left (Mail, Logs). */ .split { display: flex; height: 100vh; } -.split > aside { width: 340px; border-right: 1px solid var(--line); display: flex; flex-direction: column; } +.split > aside { width: 340px; background: var(--sidebar); border-right: 1px solid var(--line); display: flex; flex-direction: column; } .split > main { flex: 1; min-width: 0; display: flex; flex-direction: column; } .toolbar { display: flex; align-items: center; gap: 8px; padding: 8px 12px; border-bottom: 1px solid var(--line); } .scroll { flex: 1; overflow: auto; } .list > div { padding: 8px 12px; border-bottom: 1px solid var(--line); cursor: default; } -.list > div:hover { background: var(--bg-2); } +.list > div:hover { background: var(--line); } .list > div.selected { background: color-mix(in srgb, var(--accent) 16%, transparent); } .list h4 { margin: 10px 12px 4px; font-size: 11px; text-transform: uppercase; letter-spacing: 0.05em; color: var(--muted); } .tabs { display: flex; gap: 2px; padding: 0 12px; border-bottom: 1px solid var(--line); } -.tabs button { border: none; border-bottom: 2px solid transparent; border-radius: 0; background: none; padding: 6px 10px; } -.tabs button.active { border-bottom-color: var(--accent); } +.tabs button { border: none; border-bottom: 2px solid transparent; border-radius: 0; background: none; padding: 6px 10px; color: var(--muted); } +.tabs button.active { border-bottom-color: var(--accent); color: var(--fg); } diff --git a/linux/app/frontend/fonts/Inter-OFL.txt b/linux/app/frontend/fonts/Inter-OFL.txt new file mode 100644 index 00000000..9b2ca37b --- /dev/null +++ b/linux/app/frontend/fonts/Inter-OFL.txt @@ -0,0 +1,92 @@ +Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION AND CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/linux/app/frontend/fonts/JetBrainsMono-OFL.txt b/linux/app/frontend/fonts/JetBrainsMono-OFL.txt new file mode 100644 index 00000000..5ceee002 --- /dev/null +++ b/linux/app/frontend/fonts/JetBrainsMono-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/linux/app/frontend/fonts/inter.woff2 b/linux/app/frontend/fonts/inter.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..798d6d9f6cabc933d82f43e47a58b43835e4b631 GIT binary patch literal 48444 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 N2D$e+|L^@6X8`S>M5F)! literal 0 HcmV?d00001 diff --git a/linux/app/frontend/fonts/jetbrains-mono.woff2 b/linux/app/frontend/fonts/jetbrains-mono.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..cd5102a44c763f1cfe9f2b199fd5d7257491c0dd GIT binary patch literal 40404 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", + "bug": "", + "chevron-down": "", + "code": "", + "compass": "", + "eye": "", + "folder": "", + "hexagon": "", + "info-circle": "", + "lock": "", + "mail": "", + "pin": "", + "plus": "", + "server": "", + "settings": "", + "world": "" +}; + +export function icon(name, size = 16) { + const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + for (const [k, v] of Object.entries({ width: size, height: size, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", + "stroke-width": 2, "stroke-linecap": "round", "stroke-linejoin": "round", class: "icon" })) svg.setAttribute(k, v); + svg.innerHTML = paths[name] || ""; + return svg; +} diff --git a/linux/app/frontend/settings.html b/linux/app/frontend/settings.html index 292109e9..08c9d6f9 100644 --- a/linux/app/frontend/settings.html +++ b/linux/app/frontend/settings.html @@ -5,12 +5,14 @@ Flock Settings

-

+

@@ -35,6 +36,7 @@ + + diff --git a/linux/app/frontend/panel.html b/linux/app/frontend/panel.html index 9ab1954c..5b54b7f7 100644 --- a/linux/app/frontend/panel.html +++ b/linux/app/frontend/panel.html @@ -108,7 +108,8 @@

Flock

toggle("Capture dump() and events", st.dumps, "dumps")), h("div", { class: "shortcuts" }, h("button", { onclick: () => openWindow("mail") }, "Mail ", st.mail_count ? h("span", { class: "badge" }, st.mail_count) : ""), - h("button", { onclick: () => openWindow("logs") }, "Logs"))); + h("button", { onclick: () => openWindow("logs") }, "Logs"), + h("button", { onclick: () => openWindow("dumps") }, "Dumps"))); } function renderStopped(installed, message) { diff --git a/linux/app/main.go b/linux/app/main.go index 715f83fd..b310f5f9 100644 --- a/linux/app/main.go +++ b/linux/app/main.go @@ -1,5 +1,5 @@ -// flock-app is Flock's tray app for Linux: the panel from the tray icon, and the Mail and Logs -// windows, like the macOS menu bar app and the Windows tray app. The pages are plain HTML in +// flock-app is Flock's tray app for Linux: the panel from the tray icon, and the Mail, Logs +// and Dumps windows, like the macOS menu bar app and the Windows tray app. The pages are plain HTML in // frontend/; they reach the daemon's API at /api/ (passed on to its unix socket, as the CLI does) // and ask this program for what a page can't do itself at /app/. package main @@ -45,6 +45,7 @@ var windowSizes = map[string][2]int{ "panel": {380, 560}, "mail": {1100, 700}, "logs": {1000, 650}, + "dumps": {920, 620}, "settings": {980, 680}, } @@ -87,6 +88,7 @@ func main() { menu.Add("Open Flock").OnClick(func(*application.Context) { f.show("panel") }) menu.Add("Mail").OnClick(func(*application.Context) { f.show("mail") }) menu.Add("Logs").OnClick(func(*application.Context) { f.show("logs") }) + menu.Add("Dumps").OnClick(func(*application.Context) { f.show("dumps") }) menu.Add("Settings…").OnClick(func(*application.Context) { f.show("settings") }) menu.AddSeparator() menu.Add("Restart Flock").OnClick(func(*application.Context) { go f.restart() }) @@ -262,8 +264,12 @@ func (f *flockApp) handler() http.Handler { }) mux.HandleFunc("POST /app/open", func(w http.ResponseWriter, r *http.Request) { u := r.URL.Query().Get("url") - if !strings.HasPrefix(u, "http://") && !strings.HasPrefix(u, "https://") && !strings.HasPrefix(u, "mailto:") { - http.Error(w, "only web links open", http.StatusBadRequest) + // Web and mail links, and the editor links of the Dumps window (xdg-open hands them on). + scheme, _, _ := strings.Cut(u, ":") + switch strings.ToLower(scheme) { + case "http", "https", "mailto", "phpstorm", "vscode", "cursor", "zed": + default: + http.Error(w, "only web and editor links open", http.StatusBadRequest) return } _ = f.app.Browser.OpenURL(u) From 876f69a29541cd0cd7dfdb236623d7201da0176a Mon Sep 17 00:00:00 2001 From: nejcc Date: Mon, 19 Oct 2026 11:44:23 +0200 Subject: [PATCH 22/26] feat(linux): a setup wizard, a .desktop entry and flock:// links in the Linux app Setup: until Flock is set up the app opens a wizard instead of the panel. It checks for programs holding Flock's ports, then runs `flock-php install`; without a terminal the CLI now asks for the password through pkexec (the desktop's own dialog), and a dismissed dialog is reported as declined. The panel's "not set up" state leads there, and the wizard asks about opening Flock at login. flock-app.desktop: Mail, Logs, Dumps and Settings actions, and the handler for flock:// links. flock://install/[/], flock://secure/ and flock://update open the matching Settings pane, which asks before it changes anything, in the first or the running instance. --- internal/cli/install_linux.go | 21 ++- linux/app/frontend/app.css | 3 + linux/app/frontend/app.js | 25 ++++ linux/app/frontend/icons.js | 3 + linux/app/frontend/panel.html | 6 +- linux/app/frontend/settings/node.js | 29 +++- linux/app/frontend/settings/php.js | 29 +++- .../frontend/settings/services-installs.js | 50 ++++--- linux/app/frontend/settings/services.js | 24 +++- linux/app/frontend/settings/sites.js | 23 ++- linux/app/frontend/setup.html | 134 ++++++++++++++++++ linux/app/links.go | 84 +++++++++++ linux/app/links_test.go | 45 ++++++ linux/app/main.go | 40 +++++- linux/app/setup.go | 95 +++++++++++++ linux/app/setup_test.go | 53 +++++++ linux/flock-app.desktop | 29 ++++ 17 files changed, 659 insertions(+), 34 deletions(-) create mode 100644 linux/app/frontend/setup.html create mode 100644 linux/app/links.go create mode 100644 linux/app/links_test.go create mode 100644 linux/app/setup.go create mode 100644 linux/app/setup_test.go create mode 100644 linux/flock-app.desktop diff --git a/internal/cli/install_linux.go b/internal/cli/install_linux.go index 410e124d..a128fc44 100644 --- a/internal/cli/install_linux.go +++ b/internal/cli/install_linux.go @@ -23,6 +23,7 @@ import ( "github.com/spf13/cobra" "golang.org/x/sys/unix" + "golang.org/x/term" "flock/internal/api" "flock/internal/certs" @@ -292,15 +293,29 @@ func resolvesToFlock(tld string) bool { return false } +// errDeclined is what the app looks for to tell a dismissed password dialog from a failure. +var errDeclined = errors.New("administrator permission was declined") + // runPrivileged runs a command as root: directly when flock already runs as root, else through -// sudo, which asks for the password at the terminal. +// sudo, which asks for the password at the terminal. Without a terminal (the Flock app runs +// `flock-php install`) pkexec asks in the desktop's own password dialog instead. func runPrivileged(cmd *cobra.Command, exe string, args ...string) error { run := exec.Command("sudo", append([]string{exe}, args...)...) - if os.Geteuid() == 0 { + switch { + case os.Geteuid() == 0: run = exec.Command(exe, args...) + case !term.IsTerminal(int(os.Stdin.Fd())): + if _, err := exec.LookPath("pkexec"); err == nil { + run = exec.Command("pkexec", append([]string{exe}, args...)...) + } } run.Stdin, run.Stdout, run.Stderr = os.Stdin, cmd.OutOrStdout(), cmd.ErrOrStderr() - return run.Run() + err := run.Run() + // 126: pkexec's dialog was dismissed, or the user isn't allowed to authenticate. + if exit, ok := errors.AsType[*exec.ExitError](err); ok && run.Args[0] == "pkexec" && exit.ExitCode() == 126 { + return errDeclined + } + return err } // systemTrusts reports whether the system's trust store has the CA, the way Go, curl and PHP see it. diff --git a/linux/app/frontend/app.css b/linux/app/frontend/app.css index 44494f82..702f2600 100644 --- a/linux/app/frontend/app.css +++ b/linux/app/frontend/app.css @@ -167,3 +167,6 @@ input[type="checkbox"]:checked::after { transform: translateX(14px); } .tabs { display: flex; gap: 2px; padding: 0 12px; border-bottom: 1px solid var(--line); } .tabs button { border: none; border-bottom: 2px solid transparent; border-radius: 0; background: none; padding: 6px 10px; color: var(--muted); } .tabs button.active { border-bottom-color: var(--accent); color: var(--fg); } +dialog.ask { width: 460px; padding: 18px; color: var(--fg); } +dialog.ask::backdrop { background: #0006; } +dialog.ask h3 { margin: 0 0 8px; } diff --git a/linux/app/frontend/app.js b/linux/app/frontend/app.js index 1f0e4b21..3686d505 100644 --- a/linux/app/frontend/app.js +++ b/linux/app/frontend/app.js @@ -67,3 +67,28 @@ export async function pickFolder(title) { const res = await fetch("/app/pick-folder?title=" + encodeURIComponent(title), { method: "POST" }); return (await res.text()).trim(); } + +// A modal question; resolves to { checked } on OK, null on Cancel or Escape. +export function ask(title, text, ok, { danger, check } = {}) { + return new Promise((resolve) => { + const box = check && h("input", { type: "checkbox" }); + const warning = check && h("div", { class: "small warn", hidden: true }, check.warning); + box?.addEventListener("change", () => { warning.hidden = !box.checked; }); + const d = h("dialog", { class: "card ask" }, + h("h3", {}, title), h("p", { class: "muted" }, text), + check && h("label", { class: "row" }, box, check.label), warning, + h("div", { class: "row", style: "justify-content: flex-end; margin-top: 14px" }, + h("button", { onclick: () => d.close("") }, "Cancel"), + h("button", { class: "primary", style: danger ? "background: var(--bad); border-color: var(--bad)" : "", onclick: () => d.close("ok") }, ok))); + d.addEventListener("close", () => { d.remove(); resolve(d.returnValue === "ok" ? { checked: !!box?.checked } : null); }); + document.body.append(d); + d.showModal(); + }); +} + +// A flock:// link waiting for this pane to ask about it ("services", "php", "node" or "sites"), +// or null. Taking it clears it, so it's asked about once. +export async function takeLink(pane) { + const res = await fetch("/app/pending-link?pane=" + pane, { method: "POST" }); + return res.status === 200 ? res.json() : null; +} diff --git a/linux/app/frontend/icons.js b/linux/app/frontend/icons.js index 69a8f957..c7d0382c 100644 --- a/linux/app/frontend/icons.js +++ b/linux/app/frontend/icons.js @@ -1,9 +1,11 @@ // Tabler Icons (MIT, https://tabler.io/icons) used by the app, outline style. const paths = { + "alert-triangle": "", "brain": "", "brand-nodejs": "", "brand-php": "", "bug": "", + "check": "", "chevron-down": "", "code": "", "compass": "", @@ -18,6 +20,7 @@ const paths = { "plus": "", "server": "", "settings": "", + "terminal-2": "", "world": "" }; diff --git a/linux/app/frontend/panel.html b/linux/app/frontend/panel.html index 5b54b7f7..7c2aa266 100644 --- a/linux/app/frontend/panel.html +++ b/linux/app/frontend/panel.html @@ -117,9 +117,9 @@

Flock

if (!installed) { $("headline").textContent = "Not set up"; $("content").replaceChildren(h("section", {}, - h("p", {}, "Flock isn't set up for your user yet. In a terminal, run:"), - h("pre", { class: "mono" }, "flock-php install"), - h("p", { class: "muted" }, "It asks for your password once, for .test DNS, the HTTPS certificate authority and ports 80 and 443."))); + h("p", {}, "Flock isn't set up for your user yet."), + h("p", { class: "muted" }, "Setting it up asks for your password once, for .test DNS, the HTTPS certificate authority and ports 80 and 443."), + h("button", { class: "primary", onclick: () => openWindow("setup") }, "Set Up Flock…"))); return; } $("headline").textContent = "Not running"; diff --git a/linux/app/frontend/settings/node.js b/linux/app/frontend/settings/node.js index 40a4e1a8..ef3aeb4d 100644 --- a/linux/app/frontend/settings/node.js +++ b/linux/app/frontend/settings/node.js @@ -2,7 +2,7 @@ // make default, update npm and uninstall. An install runs within its request, so the row shows what // it waits for until the daemon answers. Exact versions a project pins (.nvmrc) are offered by // `node` in the terminal instead. -import { api, ago, h } from "../app.js"; +import { api, ago, ask, h, takeLink } from "../app.js"; import { icon } from "../icons.js"; let versions = null; // GET /v1/node/versions @@ -10,6 +10,12 @@ let error = ""; let checking = false; const busy = new Map(); // major → what its row shows while a request runs let installingDefault = null; + +// A flock://install link to a version this Flock can't install. +const NO_BUILD = "There's no build of it for this computer, or the link is meant for a newer Flock."; +const newest = (list, key) => list.filter((v) => v.latest).sort((a, b) => + b[key].localeCompare(a[key], undefined, { numeric: true }))[0]; +let asking = false; let loadedAt = 0; let root = null; @@ -46,6 +52,26 @@ async function run(ctx, major, step, fn) { ctx.rerender(); } +// Asks about a pending flock://install/node link (links.go) once the versions are loaded: nothing +// installs until the user confirms. Without a version, the newest line. +async function askLink(ctx, defaultInstalling) { + if (asking) return; + asking = true; + try { + const l = await takeLink("node"); + if (!l) return; + const v = l.line ? versions.versions.find((x) => x.major === l.line) : newest(versions.versions, "major"); + if (!v || (!v.installed && !v.latest)) return await ask(`Flock doesn't offer Node.js ${l.line || ""}`.trim(), NO_BUILD, "OK"); + if (v.installed) return await ask(`Node.js ${v.major} is already installed`, "You'll find it in Settings → Node.", "OK"); + if (busy.has(v.major) || v.major === defaultInstalling) return await ask(`Node.js ${v.major} is already being installed`, "Settings → Node shows how far it is.", "OK"); + if (await ask(`Install Node.js ${v.major}?`, `A link asked Flock to install Node.js ${v.latest}. Projects that use it get node, npm and npx as soon as it's installed.`, "Install")) { + run(ctx, v.major, `Downloading Node.js ${v.latest}…`, () => api(`/v1/node/versions/${v.major}/install`, { method: "POST" })); + } + } finally { + asking = false; + } +} + function row(ctx, v, defaultInstalling) { const m = v.major; const installed = !!v.installed; @@ -108,6 +134,7 @@ export default { root.append(h("div", { class: "empty" }, error || "Loading Node.js versions…")); return root; } + askLink(ctx, defaultInstalling); const check = h("button", { class: "link", disabled: checking, onclick: async () => { checking = true; diff --git a/linux/app/frontend/settings/php.js b/linux/app/frontend/settings/php.js index 8c5e5066..7c715920 100644 --- a/linux/app/frontend/settings/php.js +++ b/linux/app/frontend/settings/php.js @@ -1,7 +1,7 @@ // Settings → PHP: the PHP versions from the binaries manifest, and the FrankenPHP builds, as in the // other apps: install, update, make default and uninstall, with download progress and the install // log. The pane loads its own data, every 2 s while something downloads and every 30 s otherwise. -import { api, app, ago, h } from "../app.js"; +import { api, app, ago, ask, h, takeLink } from "../app.js"; import { icon } from "../icons.js"; let versions = null; // GET /v1/php/versions @@ -11,6 +11,12 @@ let checking = false; let logOf = null; // the key of the row whose install log is shown ("php:8.4", "frankenphp:8.4") let log = ""; const busy = new Set(); + +// A flock://install link to a version this Flock can't install. +const NO_BUILD = "There's no build of it for this computer, or the link is meant for a newer Flock."; +const newest = (list, key) => list.filter((v) => v.latest).sort((a, b) => + b[key].localeCompare(a[key], undefined, { numeric: true }))[0]; +let asking = false; let loadedAt = 0; let timer = 0; let dueAt = 0; @@ -79,6 +85,26 @@ async function run(ctx, key, fn) { await load(ctx); } +// Asks about a pending flock://install/php link (links.go) once the versions are loaded: nothing +// installs until the user confirms. Without a version, the newest that isn't a pre-release. +async function askLink(ctx) { + if (asking) return; + asking = true; + try { + const l = await takeLink("php"); + if (!l) return; + const v = l.line ? versions.versions.find((x) => x.minor === l.line) : newest(versions.versions.filter((x) => !x.prerelease), "minor"); + if (!v || (!v.installed && !v.latest)) return await ask(`Flock doesn't offer PHP ${l.line || ""}`.trim(), NO_BUILD, "OK"); + if (v.installed) return await ask(`PHP ${v.minor} is already installed`, "You'll find it in Settings → PHP.", "OK"); + if (v.job?.state === "running") return await ask(`PHP ${v.minor} is already being installed`, "Settings → PHP shows how far it is.", "OK"); + if (await ask(`Install PHP ${v.minor}?`, `A link asked Flock to install PHP ${v.latest}. Sites that use it are served as soon as it's installed.`, "Install")) { + run(ctx, "php:" + v.minor, async () => { versions = await api(`/v1/php/versions/${v.minor}/install`, { method: "POST" }); }); + } + } finally { + asking = false; + } +} + function jobText(job) { const phase = { downloading: "Downloading…", installing: "Installing…" }[job.phase] || (job.action === "update" ? "Updating…" : "Working…"); return job.phase === "downloading" && job.progress >= 0 && job.progress <= 1 ? `${phase} ${Math.round(job.progress * 100)}%` : phase; @@ -198,6 +224,7 @@ export default { if (Date.now() - loadedAt > 5000) load(ctx); if (!versions) return root; schedule(ctx); + askLink(ctx); const check = h("button", { class: "link", disabled: checking, onclick: async () => { checking = true; diff --git a/linux/app/frontend/settings/services-installs.js b/linux/app/frontend/settings/services-installs.js index 3e6b8354..033e9f89 100644 --- a/linux/app/frontend/settings/services-installs.js +++ b/linux/app/frontend/settings/services-installs.js @@ -1,11 +1,12 @@ // Settings → Services, the installs: PostgreSQL's majors and the catalog services' lines (MySQL, // Redis, Meilisearch, ...) side by side, as in the other apps. Installs run in the daemon (catalog // jobs, or PostgreSQL's busy majors); load() picks up their progress, also of the CLI's installs. -import { api, ago, h, size } from "../app.js"; +import { api, ago, ask, h, size } from "../app.js"; export let services = null; export let pg = null; // GET /v1/postgres/versions, null until loaded or where it isn't offered export let catalog = null; // GET /v1/catalog, likewise +export { ask }; export const pending = {}; // id → what the row says while this window's request runs const picked = {}; // id → { line, port } chosen before installing, kept across re-renders let checking = false; @@ -35,6 +36,35 @@ export async function load(ctx) { } } +// Asks about a flock://install link to a service (links.go): nothing installs until the user +// confirms. Without a version, the first line offered. +const NEWER = "The link may be meant for a newer Flock. Update Flock, then try again."; +export async function confirmLink(ctx, l) { + const s = services.find((x) => x.id === l.service); + if (!s) return ask(`Flock doesn't offer ${l.service}`, NEWER, "OK"); + if (!s.available) return ask(`${s.name} isn't available on this computer`, s.unavailable || "Flock can't run it on this platform.", "OK"); + let name = s.name, label, version, install; + if (s.id === "postgres") { + const m = l.line ? pg?.majors?.find((x) => x.major === l.line) : pg?.majors?.[0]; + if (!m) return ask(`Flock doesn't offer ${name} ${l.line || ""}`.trim(), NEWER, "OK"); + if (m.installed) return ask(`PostgreSQL ${m.major} is already installed`, "You'll find it in Settings → Services.", "OK"); + [label, version] = [m.major, m.latest]; + install = () => api(`/v1/postgres/versions/${m.major}/install`, { method: "POST" }); + } else { + const e = entry(s.id); + if (!e) return ask(`Flock doesn't offer ${name}`, NEWER, "OK"); + const line = l.line ? e.lines?.find((x) => x.id === l.line) : e.lines?.[0]; + name = e.name; + if (!line) return ask(`Flock doesn't offer ${name} ${l.line || ""}`.trim(), NEWER, "OK"); + [label, version] = [line.label || line.id, line.latest]; + if (line.installed) return ask(`${name} ${label} is already installed`, "You'll find it in Settings → Services.", "OK"); + install = () => api(`/v1/catalog/${e.id}/install`, { method: "POST", body: { line: line.id, port: 0 } }); + } + if (await ask(`Install ${name} ${label}?`, `A link asked Flock to install ${name} ${version || label}. It runs on 127.0.0.1 for your sites.`, "Install")) { + work(ctx, s.id, "Installing…", install); + } +} + // Runs a request with the row showing step meanwhile, then reloads. ctx.act shows a failure. async function work(ctx, id, step, fn) { pending[id] = step; @@ -45,24 +75,6 @@ async function work(ctx, id, step, fn) { ctx.rerender(); } -// A modal question; resolves to { checked } on OK, null on Cancel or Escape. -export function ask(title, text, ok, { danger, check } = {}) { - return new Promise((resolve) => { - const box = check && h("input", { type: "checkbox" }); - const warning = check && h("div", { class: "small warn", hidden: true }, check.warning); - box?.addEventListener("change", () => { warning.hidden = !box.checked; }); - const d = h("dialog", { class: "card ask" }, - h("h3", {}, title), h("p", { class: "muted" }, text), - check && h("label", { class: "row" }, box, check.label), warning, - h("div", { class: "row", style: "justify-content: flex-end; margin-top: 14px" }, - h("button", { onclick: () => d.close("") }, "Cancel"), - h("button", { class: "primary", style: danger ? "background: var(--bad); border-color: var(--bad)" : "", onclick: () => d.close("ok") }, ok))); - d.addEventListener("close", () => { d.remove(); resolve(d.returnValue === "ok" ? { checked: !!box?.checked } : null); }); - document.body.append(d); - d.showModal(); - }); -} - // The tail of a catalog install's log, followed while the install runs. function showLog(e) { const pre = h("pre", { class: "code mono selectable" }, "Loading…"); diff --git a/linux/app/frontend/settings/services.js b/linux/app/frontend/settings/services.js index c7cb0fc3..f511dfda 100644 --- a/linux/app/frontend/settings/services.js +++ b/linux/app/frontend/settings/services.js @@ -1,14 +1,30 @@ // Settings → Services: the services grouped by category on the left, the selected one's details on // the right, as in the other apps. Installing, updating and switching versions is in // services-installs.js. This pane polls while shown: installs move on without a status change. -import { api, h, openWindow } from "../app.js"; -import { ask, BUSY, catalogDetail, catalogWorking, entry, load, pending, pg, pgWorking, phase, postgresDetail, services } from "./services-installs.js"; +import { api, h, openWindow, takeLink } from "../app.js"; +import { ask, BUSY, catalogDetail, confirmLink, catalogWorking, entry, load, pending, pg, pgWorking, phase, postgresDetail, services } from "./services-installs.js"; const ORDER = ["Core", "Database", "Cache & Queue", "Search", "Storage", "Mail", "Debugging"]; let showAll = true; let selection = null; let timer = null; let root = null; +let asking = false; + +// A pending flock://install link shows its service, then asks (confirmLink). +async function askLink(ctx) { + if (asking) return; + asking = true; + try { + const l = await takeLink("services"); + if (!l) return; + selection = l.service; + ctx.rerender(); + await confirmLink(ctx, l); + } finally { + asking = false; + } +} const css = ` @@ -29,9 +45,6 @@ const css = ` .accent { color: var(--accent); } pre.code { margin: 0; padding: 8px 10px; border: 1px solid var(--line); border-radius: 6px; background: var(--bg-2); white-space: pre-wrap; word-break: break-all; } progress { width: 100%; accent-color: var(--accent); } - dialog.ask { width: 460px; padding: 18px; color: var(--fg); } - dialog.ask::backdrop { background: #0006; } - dialog.ask h3 { margin: 0 0 8px; } dialog.log { width: 720px; } dialog.log pre { height: 380px; overflow: auto; margin: 0; white-space: pre; } `; @@ -157,6 +170,7 @@ export default { }, 2000); } if (!services) return (root = h("div", { class: "empty" }, "Loading services…")); + askLink(ctx); // With "Installed", also the selected service, so showing one from elsewhere still works. const shown = showAll ? services : services.filter((s) => diff --git a/linux/app/frontend/settings/sites.js b/linux/app/frontend/settings/sites.js index ac95368f..9e6be107 100644 --- a/linux/app/frontend/settings/sites.js +++ b/linux/app/frontend/settings/sites.js @@ -2,10 +2,30 @@ // pickers, HTTPS, and actions. Each picker offers the project's pin (or the default) first, then // the installed versions, which isolate the site on this machine. The pin button opens the row's // details: pinning into the project, extra domains, the server, and removing the site. -import { api, app, h, openURL } from "../app.js"; +import { api, app, ask, h, openURL, takeLink } from "../app.js"; import { icon } from "../icons.js"; let expanded = null; +let asking = false; + +// Asks about a pending flock://secure link (links.go), from the page served for a site that +// isn't secured: nothing changes until the user confirms. +async function askLink(ctx) { + if (asking) return; + asking = true; + try { + const l = await takeLink("sites"); + if (!l) return; + const s = (ctx.status.sites || []).find((x) => x.name.toLowerCase() === l.site); + if (!s) return await ask(`Flock has no site called ${l.site}`, "Add the site in Settings → Sites first.", "OK"); + if (s.secured) return await ask(`${s.name} is already secured`, `It's served at https://${s.domain}.`, "OK"); + if (await ask(`Serve ${s.name} over HTTPS?`, `A link asked Flock to secure ${s.domain}. It gets a certificate this computer already trusts.`, "Secure")) { + await ctx.act(() => api(site(s.name), { method: "PATCH", body: { secured: true } })); + } + } finally { + asking = false; + } +} const site = (name) => `/v1/sites/${encodeURIComponent(name)}`; const css = ` @@ -123,6 +143,7 @@ export default { if (path) ctx.act(() => api("/v1/sites", { method: "POST", body: { path } })); } }, "Add Site…"), render(ctx) { + askLink(ctx); const cfg = ctx.config; const sites = [...(ctx.status.sites || [])].sort((a, b) => a.name.localeCompare(b.name)); if (!sites.length) { diff --git a/linux/app/frontend/setup.html b/linux/app/frontend/setup.html new file mode 100644 index 00000000..ccdf9411 --- /dev/null +++ b/linux/app/frontend/setup.html @@ -0,0 +1,134 @@ + + + + +Set up Flock + + + + +
+ + + + diff --git a/linux/app/links.go b/linux/app/links.go new file mode 100644 index 00000000..a0bbe76e --- /dev/null +++ b/linux/app/links.go @@ -0,0 +1,84 @@ +package main + +import ( + "net/url" + "strings" +) + +// link is a flock:// link the app handles, as in the other apps (FlockKit's InstallLink, +// SecureLink and UpdateLink): +// +// flock://install/[/] e.g. flock://install/mysql/8.4, /php/8.5, /node/24 +// flock://secure/ from the page served for a site that isn't secured +// flock://update from `flock update` +// +// Any web page can open such a link, so it never changes anything by itself: the Settings pane it +// opens asks first. +type link struct { + Kind string `json:"kind"` // "install", "secure" or "update" + Service string `json:"service,omitempty"` + Line string `json:"line,omitempty"` + Site string `json:"site,omitempty"` +} + +// pane is the Settings pane that asks about the link. +func (l link) pane() string { + switch { + case l.Kind == "secure": + return "sites" + case l.Kind == "update": + return "general" + case l.Service == "php" || l.Service == "node": + return l.Service + } + return "services" +} + +// parseLink returns the link in a command-line argument, or false when it isn't a valid one. +func parseLink(text string) (link, bool) { + if text == "" || len(text) > 200 { + return link{}, false + } + u, err := url.Parse(text) + if err != nil || !strings.EqualFold(u.Scheme, "flock") { + return link{}, false + } + var parts []string + for _, p := range strings.Split(u.Path, "/") { + if p != "" { + parts = append(parts, p) + } + } + switch strings.ToLower(u.Host) { + case "install": + if len(parts) < 1 || len(parts) > 2 || !safe(parts[0], 32) || (len(parts) == 2 && !safe(parts[1], 16)) { + return link{}, false + } + l := link{Kind: "install", Service: strings.ToLower(parts[0])} + if len(parts) == 2 { + l.Line = parts[1] + } + return l, true + case "secure": + if len(parts) != 1 || !safe(parts[0], 63) { + return link{}, false + } + return link{Kind: "secure", Site: strings.ToLower(parts[0])}, true + case "update": + return link{Kind: "update"}, len(parts) == 0 + } + return link{}, false +} + +// safe: letters, digits, dots and dashes only, so nothing odd ends up in a dialog or a request. +func safe(part string, max int) bool { + if part == "" || len(part) > max { + return false + } + for _, c := range part { + if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '.' || c == '-') { + return false + } + } + return true +} diff --git a/linux/app/links_test.go b/linux/app/links_test.go new file mode 100644 index 00000000..81b95fb0 --- /dev/null +++ b/linux/app/links_test.go @@ -0,0 +1,45 @@ +package main + +import "testing" + +func TestParseLink(t *testing.T) { + valid := map[string]link{ + "flock://install/mysql/8.4": {Kind: "install", Service: "mysql", Line: "8.4"}, + "flock://install/postgres/18": {Kind: "install", Service: "postgres", Line: "18"}, + "flock://install/meilisearch": {Kind: "install", Service: "meilisearch"}, + "FLOCK://Install/MySQL/8.4": {Kind: "install", Service: "mysql", Line: "8.4"}, + "flock://install/php/8.5": {Kind: "install", Service: "php", Line: "8.5"}, + "flock://install/node/24": {Kind: "install", Service: "node", Line: "24"}, + "flock://secure/watchtower": {Kind: "secure", Site: "watchtower"}, + "flock://update": {Kind: "update"}, + "flock://update/": {Kind: "update"}, + } + for text, want := range valid { + if got, ok := parseLink(text); !ok || got != want { + t.Errorf("parseLink(%q) = %+v, %v; want %+v", text, got, ok, want) + } + } + + for _, text := range []string{ + "", "--background", "settings", "https://install/mysql/8.4", "flock://uninstall/mysql", + "flock://install", "flock://install/mysql/8.4/extra", "flock://install/my%20sql", + "flock://install/mysql/8.4%0A", "flock://install/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "flock://secure", "flock://secure/watchtower/extra", "flock://secure/watch%20tower", + "flock://secure/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "flock://update/now", + } { + if got, ok := parseLink(text); ok { + t.Errorf("parseLink(%q) = %+v; want invalid", text, got) + } + } + + panes := map[string]string{ + "flock://install/mysql": "services", "flock://install/php/8.5": "php", "flock://install/node": "node", + "flock://secure/app": "sites", "flock://update": "general", + } + for text, want := range panes { + if l, _ := parseLink(text); l.pane() != want { + t.Errorf("%q opens %q; want %q", text, l.pane(), want) + } + } +} diff --git a/linux/app/main.go b/linux/app/main.go index b310f5f9..368e8df6 100644 --- a/linux/app/main.go +++ b/linux/app/main.go @@ -7,6 +7,7 @@ package main import ( "context" "embed" + "encoding/json" "errors" "io" "io/fs" @@ -20,6 +21,7 @@ import ( "path/filepath" "strconv" "strings" + "sync" "time" "github.com/wailsapp/wails/v3/pkg/application" @@ -47,12 +49,16 @@ var windowSizes = map[string][2]int{ "logs": {1000, 650}, "dumps": {920, 620}, "settings": {980, 680}, + "setup": {620, 600}, } type flockApp struct { app *application.App root string windows map[string]*application.WebviewWindow + + mu sync.Mutex + pending *link // a flock:// link waiting for its Settings pane to ask about it } func main() { @@ -110,8 +116,20 @@ func main() { } // open shows the window named on the command line (the panel without one), and the part of it -// named after that, e.g. `flock-app settings sites`. +// named after that, e.g. `flock-app settings sites`. A flock:// link (the .desktop entry's %u) +// opens the Settings pane that asks about it. func (f *flockApp) open(args []string) { + for _, a := range args { + if l, ok := parseLink(a); ok { + // ponytail: flock://update only opens General; on Linux the package manager or + // install.sh updates Flock. + f.mu.Lock() + f.pending = &l + f.mu.Unlock() + f.showSection("settings", l.pane()) + return + } + } for i, a := range args { if _, ok := windowSizes[a]; ok { section := "" @@ -122,6 +140,11 @@ func (f *flockApp) open(args []string) { return } } + // Until Flock is set up, the app leads to the setup. + if _, err := os.Stat(f.flock()); err != nil { + f.show("setup") + return + } f.show("panel") } @@ -347,6 +370,21 @@ func (f *flockApp) handler() http.Handler { } }) mux.HandleFunc("POST /app/quit", func(http.ResponseWriter, *http.Request) { f.app.Quit() }) + f.setupRoutes(mux) + // The pending flock:// link, taken by the pane that asks about it (?pane=services, ...). + mux.HandleFunc("POST /app/pending-link", func(w http.ResponseWriter, r *http.Request) { + f.mu.Lock() + l := f.pending + if l == nil || l.pane() != r.URL.Query().Get("pane") { + f.mu.Unlock() + w.WriteHeader(http.StatusNoContent) + return + } + f.pending = nil + f.mu.Unlock() + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(l) + }) return mux } diff --git a/linux/app/setup.go b/linux/app/setup.go new file mode 100644 index 00000000..06a087f7 --- /dev/null +++ b/linux/app/setup.go @@ -0,0 +1,95 @@ +package main + +// The setup wizard's side (setup.html): which other programs hold Flock's ports, and +// `flock-php install` run without a terminal, so its root steps ask through pkexec's password +// dialog instead of sudo. + +import ( + "bytes" + "encoding/json" + "net/http" + "os" + "os/exec" + "path/filepath" + "sync" +) + +// setupRun is the last `flock-php install` the wizard started, and its output so far. +type setupRun struct { + mu sync.Mutex + out bytes.Buffer + running bool + done bool + ok bool +} + +func (s *setupRun) Write(p []byte) (int, error) { + s.mu.Lock() + defer s.mu.Unlock() + return s.out.Write(p) +} + +// installer is the CLI that sets Flock up: flock-php on PATH (the AUR package), else the one next +// to this app (the release archive), else the copy an earlier setup put in Flock's folder. +func (f *flockApp) installer() string { + if p, err := exec.LookPath("flock-php"); err == nil { + return p + } + candidates := []string{f.flock()} + if self, err := os.Executable(); err == nil { + candidates = append([]string{filepath.Join(filepath.Dir(self), "flock-php")}, candidates...) + } + for _, p := range candidates { + if _, err := os.Stat(p); err == nil { + return p + } + } + return "" +} + +func (f *flockApp) setupRoutes(mux *http.ServeMux) { + run := &setupRun{} + + mux.HandleFunc("GET /app/setup", func(w http.ResponseWriter, _ *http.Request) { + run.mu.Lock() + defer run.mu.Unlock() + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "cli": f.installer(), "running": run.running, "done": run.done, "ok": run.ok, "output": run.out.String(), + }) + }) + // The programs to quit first, as `flock-php conflicts --json` lists them. An empty list when the + // check itself fails: install checks again and says why. + mux.HandleFunc("POST /app/setup/conflicts", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + out, err := exec.Command(f.installer(), "conflicts", "--json").Output() + if err != nil || !json.Valid(out) { + out = []byte(`{"conflicts":[]}`) + } + _, _ = w.Write(out) + }) + mux.HandleFunc("POST /app/setup/install", func(w http.ResponseWriter, _ *http.Request) { + cli := f.installer() + if cli == "" { + http.Error(w, "flock-php isn't installed", http.StatusNotFound) + return + } + run.mu.Lock() + defer run.mu.Unlock() + if run.running { + http.Error(w, "setup is already running", http.StatusConflict) + return + } + run.out.Reset() + run.running, run.done, run.ok = true, false, false + // No stdin: without a terminal, install asks for root through pkexec. + cmd := exec.Command(cli, "install") + cmd.Stdout, cmd.Stderr = run, run + go func() { + err := cmd.Run() + run.mu.Lock() + run.running, run.done, run.ok = false, true, err == nil + run.mu.Unlock() + }() + }) +} diff --git a/linux/app/setup_test.go b/linux/app/setup_test.go new file mode 100644 index 00000000..f8489c5a --- /dev/null +++ b/linux/app/setup_test.go @@ -0,0 +1,53 @@ +package main + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + "time" +) + +// A fake flock-php on PATH: conflicts lists one program, install prints a line and fails. +func TestSetupRoutes(t *testing.T) { + dir := t.TempDir() + script := "#!/bin/sh\ncase \"$1\" in\n" + + "conflicts) echo '{\"conflicts\":[{\"port\":80,\"service\":\"HTTP\",\"holder\":\"nginx\",\"app\":\"nginx\"}]}' ;;\n" + + "install) [ -t 0 ] && exit 9; echo 'administrator permission was declined'; exit 1 ;;\nesac\n" + if err := os.WriteFile(filepath.Join(dir, "flock-php"), []byte(script), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", dir+":"+os.Getenv("PATH")) + + mux := http.NewServeMux() + (&flockApp{root: t.TempDir()}).setupRoutes(mux) + call := func(method, path string) *httptest.ResponseRecorder { + w := httptest.NewRecorder() + mux.ServeHTTP(w, httptest.NewRequest(method, path, nil)) + return w + } + + var c struct{ Conflicts []map[string]any } + if err := json.Unmarshal(call("POST", "/app/setup/conflicts").Body.Bytes(), &c); err != nil || len(c.Conflicts) != 1 { + t.Fatalf("conflicts = %+v, %v", c, err) + } + + if w := call("POST", "/app/setup/install"); w.Code != http.StatusOK { + t.Fatalf("install: %d %s", w.Code, w.Body) + } + var st struct { + Running, Done, OK bool + Output string + } + for deadline := time.Now().Add(5 * time.Second); time.Now().Before(deadline); time.Sleep(20 * time.Millisecond) { + _ = json.Unmarshal(call("GET", "/app/setup").Body.Bytes(), &st) + if st.Done { + break + } + } + if !st.Done || st.OK || st.Output != "administrator permission was declined\n" { + t.Fatalf("setup state = %+v", st) + } +} diff --git a/linux/flock-app.desktop b/linux/flock-app.desktop new file mode 100644 index 00000000..ee607b20 --- /dev/null +++ b/linux/flock-app.desktop @@ -0,0 +1,29 @@ +[Desktop Entry] +Type=Application +Name=Flock +GenericName=Local PHP development +Comment=Local PHP development: sites, PHP, services, mail and dumps +Exec=flock-app %u +Icon=flock-app +Terminal=false +Categories=Development; +Keywords=php;laravel;local;development; +StartupWMClass=flock-app +MimeType=x-scheme-handler/flock; +Actions=Mail;Logs;Dumps;Settings; + +[Desktop Action Mail] +Name=Mail +Exec=flock-app mail + +[Desktop Action Logs] +Name=Logs +Exec=flock-app logs + +[Desktop Action Dumps] +Name=Dumps +Exec=flock-app dumps + +[Desktop Action Settings] +Name=Settings +Exec=flock-app settings From 74a7f870257ccacf34d416dfa6d075a595ac041f Mon Sep 17 00:00:00 2001 From: nejcc Date: Mon, 19 Oct 2026 11:44:23 +0200 Subject: [PATCH 23/26] feat(linux): a release archive, CI build and AUR package for the Linux app `make linux-app-archive` builds flock-app--linux-x64.tar.gz with the app, its .desktop entry and icon, and the license files. build.yml builds it on Ubuntu with GTK 3 and WebKitGTK 4.1; beta.yml and release.yml require and attach it. THIRD_PARTY_NOTICES now covers the Linux CLI and the app's extra modules and fonts. packaging/aur/flock-php-app-bin installs it with the CLI package as a dependency, with SKIP checksums until a release has the archive. --- .github/workflows/beta.yml | 3 +- .github/workflows/build.yml | 30 ++ .github/workflows/release.yml | 3 +- Makefile | 14 +- THIRD_PARTY_NOTICES | 298 ++++++++++++++++++ packaging/aur/flock-php-app-bin/PKGBUILD | 29 ++ .../flock-php-app-bin.install | 3 + tools/notices/main.go | 104 ++++-- 8 files changed, 454 insertions(+), 30 deletions(-) create mode 100644 packaging/aur/flock-php-app-bin/PKGBUILD create mode 100644 packaging/aur/flock-php-app-bin/flock-php-app-bin.install diff --git a/.github/workflows/beta.yml b/.github/workflows/beta.yml index c04f5669..f27e4929 100644 --- a/.github/workflows/beta.yml +++ b/.github/workflows/beta.yml @@ -101,7 +101,8 @@ jobs: run: | go run ./tools/release notes > "$RUNNER_TEMP/notes.md" for f in "dist/Flock-$VERSION-macOS.dmg" "dist/Flock-Setup-$VERSION-x64.exe" \ - "dist/flock-$VERSION-linux-x64.tar.gz" "dist/flock-$VERSION-linux-arm64.tar.gz"; do + "dist/flock-$VERSION-linux-x64.tar.gz" "dist/flock-$VERSION-linux-arm64.tar.gz" \ + "dist/flock-app-$VERSION-linux-x64.tar.gz"; do [ -f "$f" ] || { echo "::error::$f missing"; exit 1; } done gh release create "v$VERSION" --repo "$GITHUB_REPOSITORY" --target "$SHA" --prerelease --latest=false \ diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 086571f4..c50b5d2a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -299,3 +299,33 @@ jobs: dist/flock-*-linux-*.tar.gz dist/flock-*-linux-*.tar.gz.sha256 if-no-files-found: error + + linux-app: + name: Linux app (x64) + needs: version + if: github.event_name != 'pull_request' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + ref: ${{ inputs.ref || github.event.pull_request.head.sha }} + + - uses: actions/setup-go@v7 + with: + go-version-file: linux/go.mod + cache-dependency-path: linux/go.sum + + # cgo against GTK 3 and WebKitGTK 4.1 (Wails' gtk3 build tag). + - name: Install GTK and WebKitGTK + run: sudo apt-get update && sudo apt-get install -y --no-install-recommends libgtk-3-dev libwebkit2gtk-4.1-dev + + - name: Build archive + run: make linux-app-archive VERSION=${{ needs.version.outputs.version }} + + - uses: actions/upload-artifact@v7 + with: + name: flock-linux-app + path: | + dist/flock-app-*-linux-x64.tar.gz + dist/flock-app-*-linux-x64.tar.gz.sha256 + if-no-files-found: error diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a3806980..f14a0551 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -129,7 +129,8 @@ jobs: run: | ls dist for f in "dist/Flock-$VERSION-macOS.dmg" "dist/Flock-Setup-$VERSION-x64.exe" \ - "dist/flock-$VERSION-linux-x64.tar.gz" "dist/flock-$VERSION-linux-arm64.tar.gz"; do + "dist/flock-$VERSION-linux-x64.tar.gz" "dist/flock-$VERSION-linux-arm64.tar.gz" \ + "dist/flock-app-$VERSION-linux-x64.tar.gz"; do [ -f "$f" ] || { echo "::error::$f missing"; exit 1; } done gh release upload "$TAG" dist/* --clobber --repo "$GITHUB_REPOSITORY" diff --git a/Makefile b/Makefile index d168f499..51a4f78d 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev) LDFLAGS := -s -w -X flock/internal/cli.Version=$(VERSION) -.PHONY: build build-all test lint notices app dmg windows windows-test linux linux-app +.PHONY: build build-all test lint notices app dmg windows windows-test linux linux-app linux-app-archive build: go build -ldflags "$(LDFLAGS)" -o bin/flock ./cmd/flock @@ -53,6 +53,18 @@ linux: linux-app: cd linux && go build -tags gtk3 -trimpath -ldflags "-s -w" -o ../bin/flock-app ./app +# The Linux app as a release archive (the AUR package flock-php-app downloads it), x64 only: it needs +# cgo against GTK and WebKitGTK, so arm64 would need an arm64 runner. +# dist/flock-app--linux-x64.tar.gz with flock-app, its .desktop entry and icon, and its .sha256. +linux-app-archive: linux-app + name=flock-app-$(VERSION)-linux-x64; \ + rm -rf dist/$$name && mkdir -p dist/$$name && \ + cp bin/flock-app linux/flock-app.desktop LICENSE NOTICE THIRD_PARTY_NOTICES dist/$$name/ && \ + cp linux/app/icons/AppIcon.png dist/$$name/flock-app.png && \ + tar -C dist/$$name -czf dist/$$name.tar.gz flock-app flock-app.desktop flock-app.png LICENSE NOTICE THIRD_PARTY_NOTICES && \ + rm -r dist/$$name && \ + cd dist && sha256sum $$name.tar.gz > $$name.tar.gz.sha256 + # The tray app's models and API client (windows/FlockKit) run on any OS; the app itself is built on Windows (windows/build.ps1). windows-test: dotnet test windows/FlockKit.Tests diff --git a/THIRD_PARTY_NOTICES b/THIRD_PARTY_NOTICES index af7424ad..7e32bc23 100644 --- a/THIRD_PARTY_NOTICES +++ b/THIRD_PARTY_NOTICES @@ -4189,6 +4189,21 @@ github.com/prometheus/otlptranslator v1.0.0 LICENSE: the same text as for cel.dev/expr v0.25.3 above. +github.com/prometheus/procfs v0.22.0 +------------------------------------ + +LICENSE: the same text as for cel.dev/expr v0.25.3 above. + +NOTICE: + +procfs provides functions to retrieve system, kernel and process +metrics from the pseudo-filesystem proc. + +Copyright 2014-2015 The Prometheus Authors + +This product includes software developed at +SoundCloud Ltd. (http://soundcloud.com/). + github.com/quic-go/qpack v0.6.0 ------------------------------- @@ -6821,3 +6836,286 @@ Copyright (c) Microsoft Corporation. The Windows App SDK runtime files are not c license; they are distributed under the Microsoft Software License Terms for the Windows App SDK: https://www.nuget.org/packages/Microsoft.WindowsAppSDK/#licenseinfo-body-tab Its own third-party notices: https://github.com/microsoft/WindowsAppSDK/blob/main/NOTICE.txt + +The Linux app (flock-app): Go modules beyond the ones above +=========================================================== + +github.com/adrg/xdg v0.5.3 +-------------------------- + +LICENSE: + +The MIT License (MIT) + +Copyright (c) 2014 Adrian-George Bostan + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +github.com/godbus/dbus/v5 v5.2.2 +-------------------------------- + +LICENSE: + +Copyright (c) 2013, Georg Reinke (), Google +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + +1. Redistributions of source code must retain the above copyright notice, +this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright +notice, this list of conditions and the following disclaimer in the +documentation and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED +TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +github.com/wailsapp/wails/v3 v3.0.0-beta.32 +------------------------------------------- + +LICENSE: + +MIT License + +Copyright (c) 2018-Present Lea Anthony + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +The Linux app embeds these fonts, under the SIL Open Font License 1.1: + +Inter +----- + +Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION AND CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. + +JetBrains Mono +-------------- + +Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/packaging/aur/flock-php-app-bin/PKGBUILD b/packaging/aur/flock-php-app-bin/PKGBUILD new file mode 100644 index 00000000..3c8fc246 --- /dev/null +++ b/packaging/aur/flock-php-app-bin/PKGBUILD @@ -0,0 +1,29 @@ +# Flock's Linux app from the release archive: the tray icon, panel, Settings, Mail, Logs and Dumps, +# as /usr/bin/flock-app with its .desktop entry and icon. It runs the CLI from flock-php-bin. +# +# For a new release: set _tag, then `updpkgsums && makepkg --printsrcinfo > .SRCINFO`, and push this +# directory to ssh://aur@aur.archlinux.org/flock-php-app-bin.git. + +pkgname=flock-php-app-bin +_tag=1.0.0-beta.10 +pkgver=${_tag/-/} +pkgrel=1 +pkgdesc='The tray app for Flock: sites, PHP and Node versions, services, mail and dumps' +arch=(x86_64) +url='https://github.com/flock-php/flock' +license=(AGPL-3.0-only) +depends=(flock-php gtk3 webkit2gtk-4.1) +optdepends=('gnome-shell-extension-appindicator: the tray icon on GNOME') +provides=(flock-php-app) +conflicts=(flock-php-app) +install=flock-php-app-bin.install +options=(!strip !debug) +source_x86_64=("$url/releases/download/v$_tag/flock-app-$_tag-linux-x64.tar.gz") +sha256sums_x86_64=(SKIP) + +package() { + install -Dm755 flock-app "$pkgdir/usr/bin/flock-app" + install -Dm644 flock-app.desktop "$pkgdir/usr/share/applications/flock-app.desktop" + install -Dm644 flock-app.png "$pkgdir/usr/share/icons/hicolor/256x256/apps/flock-app.png" + install -Dm644 -t "$pkgdir/usr/share/licenses/$pkgname" LICENSE NOTICE THIRD_PARTY_NOTICES +} diff --git a/packaging/aur/flock-php-app-bin/flock-php-app-bin.install b/packaging/aur/flock-php-app-bin/flock-php-app-bin.install new file mode 100644 index 00000000..1ef50e58 --- /dev/null +++ b/packaging/aur/flock-php-app-bin/flock-php-app-bin.install @@ -0,0 +1,3 @@ +post_install() { + echo '==> Open Flock from your app launcher, or run flock-app. Set Flock up first if you haven'"'"'t: flock-php install' +} diff --git a/tools/notices/main.go b/tools/notices/main.go index e8af65fd..c5f26af7 100644 --- a/tools/notices/main.go +++ b/tools/notices/main.go @@ -1,7 +1,8 @@ // Command notices writes THIRD_PARTY_NOTICES: the licenses of the third-party code that ships in -// Flock's builds. That is the Go modules linked into flock and flock-helper (for macOS and -// Windows), the PHP packages embedded for the dumps server, Sparkle in the macOS app, and the -// runtimes the Windows tray app bundles. Run it from the repository root after changing dependencies: +// Flock's builds. That is the Go modules linked into flock and flock-helper (for macOS, Windows +// and Linux), the PHP packages embedded for the dumps server, Sparkle in the macOS app, the +// runtimes the Windows tray app bundles, and the Go modules and fonts of the Linux app +// (linux/, a module of its own). Run it from the repository root after changing dependencies: // // go run ./tools/notices // @@ -26,10 +27,18 @@ import ( const output = "THIRD_PARTY_NOTICES" // The builds Flock ships; each links a slightly different set of modules. -var targets = []struct{ goos, goarch string }{{"darwin", "arm64"}, {"windows", "amd64"}} +var targets = []struct{ goos, goarch string }{{"darwin", "arm64"}, {"windows", "amd64"}, {"linux", "amd64"}} var programs = []string{"./cmd/flock", "./cmd/flock-helper"} +// The Linux app: its module, its program, its build tag, and the fonts its pages embed. +const linuxModule, linuxProgram, linuxTags = "linux", "./app", "gtk3" + +var linuxFonts = []struct{ name, file string }{ + {"Inter", "linux/app/frontend/fonts/Inter-OFL.txt"}, + {"JetBrains Mono", "linux/app/frontend/fonts/JetBrainsMono-OFL.txt"}, +} + const phpVendor = "internal/dumps/server/vendor" // License files at a module's root. NOTICE and PATENTS files must travel with the code too. @@ -53,7 +62,11 @@ func main() { if err != nil { fail(err) } - all = append(goMods, phpPkgs...) + linuxMods, err := linuxAppModules(goMods) + if err != nil { + fail(err) + } + all = append(append(goMods, phpPkgs...), linuxMods...) var missing []string for _, c := range all { @@ -65,7 +78,7 @@ func main() { fail(fmt.Errorf("no license file found for:\n %s", strings.Join(missing, "\n "))) } - text, err := render(goMods, phpPkgs) + text, err := render(goMods, phpPkgs, linuxMods) if err != nil { fail(err) } @@ -84,32 +97,58 @@ func main() { func goModules() ([]component, error) { seen := map[string]component{} for _, t := range targets { - cmd := exec.Command("go", append([]string{"list", "-deps", "-f", - "{{with .Module}}{{if not .Main}}{{.Path}}\t{{.Version}}\t{{.Dir}}{{end}}{{end}}"}, programs...)...) - cmd.Env = append(os.Environ(), "GOOS="+t.goos, "GOARCH="+t.goarch, "CGO_ENABLED=0") - cmd.Stderr = os.Stderr - out, err := cmd.Output() - if err != nil { + if err := listModules(seen, "", []string{"GOOS=" + t.goos, "GOARCH=" + t.goarch, "CGO_ENABLED=0"}, programs...); err != nil { return nil, fmt.Errorf("go list for %s/%s: %w", t.goos, t.goarch, err) } - for line := range strings.Lines(string(out)) { - fields := strings.Split(strings.TrimSpace(line), "\t") - if len(fields) != 3 { - continue - } - if _, ok := seen[fields[0]]; ok { - continue - } - files, err := licenseFiles(fields[2]) - if err != nil { - return nil, err - } - seen[fields[0]] = component{name: fields[0], version: fields[1], files: files} - } } return sorted(seen), nil } +// linuxAppModules lists the Go modules linked into flock-app that the CLI doesn't already link +// (Wails and its dependencies). flock itself comes in through a replace and is skipped. +func linuxAppModules(cli []component) ([]component, error) { + seen := map[string]component{"flock": {}} + for _, c := range cli { + seen[c.name] = c + } + found := map[string]component{} + if err := listModules(found, linuxModule, []string{"GOOS=linux", "GOARCH=amd64", "CGO_ENABLED=1"}, "-tags", linuxTags, linuxProgram); err != nil { + return nil, fmt.Errorf("go list for the Linux app: %w", err) + } + for name := range seen { + delete(found, name) + } + return sorted(found), nil +} + +// listModules adds the non-main modules the packages depend on to seen, run in dir. +func listModules(seen map[string]component, dir string, env []string, args ...string) error { + cmd := exec.Command("go", append([]string{"list", "-deps", "-f", + "{{with .Module}}{{if not .Main}}{{.Path}}\t{{.Version}}\t{{.Dir}}{{end}}{{end}}"}, args...)...) + cmd.Dir = dir + cmd.Env = append(os.Environ(), env...) + cmd.Stderr = os.Stderr + out, err := cmd.Output() + if err != nil { + return err + } + for line := range strings.Lines(string(out)) { + fields := strings.Split(strings.TrimSpace(line), "\t") + if len(fields) != 3 { + continue + } + if _, ok := seen[fields[0]]; ok { + continue + } + files, err := licenseFiles(fields[2]) + if err != nil { + return err + } + seen[fields[0]] = component{name: fields[0], version: fields[1], files: files} + } + return nil +} + // phpPackages lists the Composer packages vendored for the dumps server (embedded in flock), plus // Composer's own autoloader. func phpPackages() ([]component, error) { @@ -162,7 +201,7 @@ func sorted(m map[string]component) []component { return list } -func render(goMods, phpPkgs []component) ([]byte, error) { +func render(goMods, phpPkgs, linuxMods []component) ([]byte, error) { var b bytes.Buffer b.WriteString(header) @@ -199,6 +238,17 @@ func render(goMods, phpPkgs []component) ([]byte, error) { b.WriteString(macOS) b.Write(normalize(sparkleLicense)) b.WriteString(windows) + if err := section("The Linux app (flock-app): Go modules beyond the ones above", linuxMods); err != nil { + return nil, err + } + b.WriteString("\nThe Linux app embeds these fonts, under the SIL Open Font License 1.1:\n") + for _, f := range linuxFonts { + data, err := os.ReadFile(f.file) + if err != nil { + return nil, err + } + fmt.Fprintf(&b, "\n%s\n%s\n\n%s", f.name, strings.Repeat("-", len(f.name)), normalize(data)) + } return b.Bytes(), nil } From 3540b9b65f17263304148f7c4fec0c692024c956 Mon Sep 17 00:00:00 2001 From: nejcc Date: Mon, 19 Oct 2026 11:49:22 +0200 Subject: [PATCH 24/26] feat(linux): Boost and PHP extensions in the Linux app's Sites pane MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A site's details list its PHP extensions: what composer.json and the locked packages require, what a Laravel app's configuration shows it uses, and whether the site's PHP has each, with "Add to composer.json…" (composer require ext-:*) after a confirm. When the project uses Laravel Boost without Flock's guideline, or with an outdated one, they offer to add or update it. Removing a site asks in the app's own dialog instead of the browser's confirm(). --- linux/app/frontend/settings/sites.js | 74 ++++++++++++++++++++++++++-- 1 file changed, 70 insertions(+), 4 deletions(-) diff --git a/linux/app/frontend/settings/sites.js b/linux/app/frontend/settings/sites.js index 9e6be107..2ea8a46a 100644 --- a/linux/app/frontend/settings/sites.js +++ b/linux/app/frontend/settings/sites.js @@ -41,7 +41,10 @@ const css = ` table.sites .actions { white-space: nowrap; text-align: right; } table.sites tr.details td { background: color-mix(in srgb, var(--bg) 50%, transparent); padding: 4px 12px 14px; } .detail-grid { display: grid; grid-template-columns: 120px 1fr; gap: 10px 14px; align-items: center; } - .detail-grid > .k { color: var(--muted); } + .detail-grid > .k { color: var(--muted); align-self: start; padding-top: 5px; } + .exts { display: flex; flex-direction: column; gap: 6px; } + .exts .ext { display: flex; align-items: center; gap: 10px; } + .exts .dot { width: 8px; height: 8px; border-radius: 50%; flex: none; } .chips { display: flex; flex-wrap: wrap; gap: 6px; } .chip { display: inline-flex; align-items: center; gap: 4px; padding: 1px 2px 1px 9px; border: 1px solid var(--line-strong); border-radius: 12px; } .footnote { margin: 10px 4px; color: var(--muted); font-size: 12px; } @@ -104,11 +107,74 @@ function details(ctx, s) { h("span", { class: "k" }, "Server"), h("div", { class: "row" }, server, worker, fp && h("span", { class: "muted" }, fp.problem || [fp.state, fp.version].filter(Boolean).join(" · ")), fp && h("button", { onclick: () => ctx.act(() => api(`${site(s.name)}/restart`, { method: "POST" })) }, "Restart")), + h("span", { class: "k" }, "PHP extensions"), extensions(ctx, s), + (s.boost === "missing" || s.boost === "outdated") && h("span", { class: "k" }, "Laravel Boost"), + (s.boost === "missing" || s.boost === "outdated") && boost(ctx, s), !s.parked && h("span", { class: "k" }, ""), - !s.parked && h("div", {}, h("button", { onclick: () => confirm(`Stop serving ${s.domain}? The folder stays as it is.`) && + !s.parked && h("div", {}, h("button", { onclick: async () => await ask(`Stop serving ${s.name}?`, `${s.domain} stops working. The folder stays as it is.`, "Remove Site", { danger: true }) && ctx.act(() => api(site(s.name), { method: "DELETE" })) }, "Remove Site"))))); } +// The project uses Laravel Boost, and Flock's guideline is missing from it or outdated. +function boost(ctx, s) { + const missing = s.boost === "missing"; + return h("div", {}, + h("div", { class: "muted" }, missing + ? "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."), + h("button", { class: "link", onclick: async () => { + const ok = await ask(missing ? `Add Flock to ${s.name}'s Boost guidelines?` : `Update Flock's guideline in ${s.name}?`, + `Writes .ai/guidelines/flock.md in ${s.path} and runs php artisan boost:update, so AI agents know Flock serves the app and picks its PHP version. Commit the changes with the project.`, + missing ? "Add" : "Update"); + if (ok) ctx.act(() => api(`${site(s.name)}/boost`, { method: "POST" })); + } }, missing ? "Add Flock to its guidelines…" : "Update it…")); +} + +// 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 the site's PHP has each. +// Loaded when the row's details open; kept per site until the details close. +const exts = new Map(); // site name → { report, error, adding } + +function extensions(ctx, s) { + let e = exts.get(s.name); + if (!e) { + e = {}; + exts.set(s.name, e); + api(`${site(s.name)}/extensions`).then((r) => { e.report = r; }, (err) => { e.error = err.message; }).then(ctx.rerender); + } + const require = async (name) => { + if (!await ask(`Require ext-${name} in composer.json?`, `Runs composer require ext-${name}:* in ${s.path}, which changes composer.json and composer.lock. Commit them with the project.`, "Require")) return; + e.adding = name; + e.error = null; + ctx.rerender(); + try { + e.report = await api(`${site(s.name)}/extensions/${encodeURIComponent(name)}/require`, { method: "POST" }); + } catch (err) { + e.error = err.message; + } + e.adding = null; + ctx.rerender(); + }; + const r = e.report; + return h("div", { class: "exts" }, + e.error && h("div", { style: "color: var(--bad)" }, "⚠ " + e.error), + !r && !e.error && h("span", { class: "muted" }, "Loading…"), + r && h("div", { class: "muted" }, [r.php ? `PHP ${r.php}` : "No PHP version yet", r.laravel && "Laravel app"].filter(Boolean).join(" · ")), + r && !r.extensions.length && h("div", { class: "muted" }, "The project requires no PHP extensions."), + ...(r?.extensions || []).map((x) => { + const declared = x.required_by?.length > 0; + const color = !x.available ? "var(--bad)" : declared ? "var(--ok)" : "var(--warn)"; + let detail = declared ? `Required by ${x.required_by.join(", ")}` : `Not required, but used: ${x.hint}`; + if (!x.available && r.php) detail += ` — Flock's PHP ${r.php} doesn't include it`; + return h("div", { class: "ext" }, + h("span", { class: "dot", style: `background:${color}` }), + h("div", { class: "grow" }, h("strong", {}, x.name), h("div", { class: "muted" }, detail)), + e.adding === x.name ? h("span", { class: "muted" }, "Requiring…") + : !declared && h("button", { disabled: !!e.adding, onclick: () => require(x.name) }, "Add to composer.json…")); + }), + r?.extensions.length > 0 && h("div", { class: "muted", style: "font-size: 12px" }, "Requiring an extension in composer.json makes composer install refuse a server without it, instead of the app failing there.")); +} + function row(ctx, s, cfg) { const secured = h("input", { type: "checkbox", checked: s.secured, title: "HTTPS with Flock's local certificate" }); secured.addEventListener("change", () => ctx.act(() => api(site(s.name), { method: "PATCH", body: { secured: secured.checked } }))); @@ -128,8 +194,8 @@ function row(ctx, s, cfg) { h("td", { class: "actions" }, h("button", { class: "ghost", title: "Open in the browser", onclick: () => openURL(url) }, icon("compass", 17)), h("button", { class: "ghost", title: "Show the folder", onclick: () => app("folder?path=" + encodeURIComponent(s.path)) }, icon("folder", 17)), - h("button", { class: "ghost", title: "Pins, domains and server", style: expanded === s.name ? "color: var(--accent)" : "", - onclick: () => { expanded = expanded === s.name ? null : s.name; ctx.rerender(); } }, icon("pin", 17)))); + h("button", { class: "ghost", title: "Pins, domains, server, extensions", style: expanded === s.name ? "color: var(--accent)" : "", + onclick: () => { exts.delete(s.name); expanded = expanded === s.name ? null : s.name; ctx.rerender(); } }, icon("pin", 17)))); } export default { From a36d884c81173dd2d36a2cd42a83338be8a3194c Mon Sep 17 00:00:00 2001 From: nejcc Date: Mon, 19 Oct 2026 13:12:53 +0200 Subject: [PATCH 25/26] fix(cli): keep sudo for flock-php install without a desktop session pkexec is only used when there's no terminal, a graphical session, and sudo would need a password. ssh -T, Ansible and CI keep using sudo, as they have no polkit agent. pkexec's 127 now reads as a missing polkit authentication agent. --- internal/cli/install_linux.go | 29 ++++++++++++++++++++++------- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/internal/cli/install_linux.go b/internal/cli/install_linux.go index a128fc44..8de6a0e3 100644 --- a/internal/cli/install_linux.go +++ b/internal/cli/install_linux.go @@ -304,20 +304,35 @@ func runPrivileged(cmd *cobra.Command, exe string, args ...string) error { switch { case os.Geteuid() == 0: run = exec.Command(exe, args...) - case !term.IsTerminal(int(os.Stdin.Fd())): - if _, err := exec.LookPath("pkexec"); err == nil { - run = exec.Command("pkexec", append([]string{exe}, args...)...) - } + case usePkexec(): + run = exec.Command("pkexec", append([]string{exe}, args...)...) } run.Stdin, run.Stdout, run.Stderr = os.Stdin, cmd.OutOrStdout(), cmd.ErrOrStderr() err := run.Run() - // 126: pkexec's dialog was dismissed, or the user isn't allowed to authenticate. - if exit, ok := errors.AsType[*exec.ExitError](err); ok && run.Args[0] == "pkexec" && exit.ExitCode() == 126 { - return errDeclined + if exit, ok := errors.AsType[*exec.ExitError](err); ok && run.Args[0] == "pkexec" { + switch exit.ExitCode() { + case 126: // the dialog was dismissed, or the user isn't allowed to authenticate + return errDeclined + case 127: + return errors.New("no polkit authentication agent is running to ask for the administrator password") + } } return err } +// usePkexec reports whether the password should be asked in the desktop's dialog: no terminal, +// a graphical session, and a sudo that would need a password. ssh -T, Ansible and CI (no +// display, or NOPASSWD sudo) keep using sudo, as they have no polkit agent to answer pkexec. +func usePkexec() bool { + if term.IsTerminal(int(os.Stdin.Fd())) || os.Getenv("WAYLAND_DISPLAY") == "" && os.Getenv("DISPLAY") == "" { + return false + } + if _, err := exec.LookPath("pkexec"); err != nil { + return false + } + return exec.Command("sudo", "-n", "true").Run() != nil +} + // systemTrusts reports whether the system's trust store has the CA, the way Go, curl and PHP see it. func systemTrusts(caPath string) bool { data, err := os.ReadFile(caPath) From a220bb345a9c0035293d3e6af6a209cf785f246b Mon Sep 17 00:00:00 2001 From: nejcc Date: Mon, 19 Oct 2026 13:12:53 +0200 Subject: [PATCH 26/26] fix(linux): start the tray watcher after the app, and guard the app's pages - watch() called InvokeSync before app.Run(), which panicked at start whenever the daemon answered first. It now starts on ApplicationStarted. - The tray, menu, second instance and app events run off the main thread, so showing a window goes through InvokeSync. - /app/folder only opens an existing absolute folder (or php.ini). - The pages get a CSP; frame-src about: keeps the mail and dump frames from navigating away. --- linux/app/frontend/dumps.html | 1 + linux/app/frontend/logs.html | 1 + linux/app/frontend/mail.html | 1 + linux/app/frontend/panel.html | 1 + linux/app/frontend/settings.html | 1 + linux/app/frontend/setup.html | 1 + linux/app/main.go | 44 ++++++++++++++++++++++---------- 7 files changed, 36 insertions(+), 14 deletions(-) diff --git a/linux/app/frontend/dumps.html b/linux/app/frontend/dumps.html index 156b47bc..54421bbf 100644 --- a/linux/app/frontend/dumps.html +++ b/linux/app/frontend/dumps.html @@ -2,6 +2,7 @@ + Flock Dumps