diff --git a/.github/workflows/beta.yml b/.github/workflows/beta.yml index fef0ea8b..f27e4929 100644 --- a/.github/workflows/beta.yml +++ b/.github/workflows/beta.yml @@ -100,7 +100,9 @@ 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" \ + "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 dc172f64..c50b5d2a 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) @@ -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 @@ -272,3 +274,58 @@ 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 + + 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 d71db055..f14a0551 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -128,7 +128,9 @@ 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" \ + "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 0c70f37b..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 +.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 @@ -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 ./... @@ -33,6 +35,36 @@ 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 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-php ./cmd/flock && \ + cp LICENSE NOTICE THIRD_PARTY_NOTICES dist/$$name/ && \ + 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 + +# 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 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/install.sh b/install.sh new file mode 100755 index 00000000..82d91bae --- /dev/null +++ b/install.sh @@ -0,0 +1,66 @@ +#!/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-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-php 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-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-php" install "$@" missing or overridden) nothing under the TLD does. Wildcard bool `json:"wildcard"` 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]) } 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) + } +} diff --git a/internal/cli/install.go b/internal/cli/install.go index 50192a67..bbc1c6b7 100644 --- a/internal/cli/install.go +++ b/internal/cli/install.go @@ -14,11 +14,15 @@ import ( "flock/internal/certs" "flock/internal/node" + "flock/internal/paths" "flock/internal/php" ) 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 +101,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 @@ -146,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_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_linux.go b/internal/cli/install_linux.go new file mode 100644 index 00000000..8de6a0e3 --- /dev/null +++ b/internal/cli/install_linux.go @@ -0,0 +1,626 @@ +package cli + +import ( + "bytes" + "context" + "crypto/rand" + "crypto/x509" + "encoding/binary" + "encoding/hex" + "encoding/pem" + "errors" + "fmt" + "net" + "os" + "os/exec" + "path/filepath" + "regexp" + "slices" + "strconv" + "strings" + "syscall" + "time" + + "github.com/spf13/cobra" + "golang.org/x/sys/unix" + "golang.org/x/term" + + "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 + } + // 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-php 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", paths.CLI()) + if _, err := os.Stat(bin); err != nil { + return fmt.Errorf("the background daemon is not installed — run `flock-php 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 (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 + 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) + } + } + 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) + 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) + } + 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 +} + +// 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 +} + +// 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. 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...)...) + switch { + case os.Geteuid() == 0: + run = exec.Command(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() + 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) + 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") + } + 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 + } + 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 + } + for _, args := range [][]string{{"daemon-reload"}, {"enable", dns.Unit}, {"restart", dns.Unit}} { + if err := runTool("systemctl", args...); 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()) + + 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/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) + } +} 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/cli/update_linux.go b/internal/cli/update_linux.go new file mode 100644 index 00000000..c2d041cd --- /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-php from https://github.com/flock-php/flock/releases and run its `flock-php install`") +} diff --git a/internal/daemon/wildcard.go b/internal/daemon/wildcard.go index faaba336..06d7a578 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 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() { if !d.checking.CompareAndSwap(false, true) { return @@ -64,12 +66,15 @@ 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: + 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: + state.problem = linuxWildcardProblem(cfg.TLD, cfg.Ports.DNS) } d.mu.Lock() @@ -93,7 +98,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 +114,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 (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 e8091d7e..cf2567ab 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 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.UnitDir = dir } func waitStatus(t *testing.T, client *api.Client, ok func(api.Status) bool) api.Status { @@ -110,11 +112,26 @@ 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 == "" }) + + // 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 new file mode 100644 index 00000000..480cb2d5 --- /dev/null +++ b/internal/dns/resolved.go @@ -0,0 +1,161 @@ +package dns + +import ( + "errors" + "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 +// 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" + +// 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" + // 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) } + +// 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 +// 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") } + +// 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 +[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. +func ResolvedRunning() bool { + _, err := os.Stat(ResolvedRuntimeDir) + 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 +} + +// 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 { + 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() + 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-php install`", path, tld) + case err != nil: + 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 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) + } + return LinkProblem(tld) +} diff --git a/internal/dns/resolved_test.go b/internal/dns/resolved_test.go new file mode 100644 index 00000000..cb7e5e0f --- /dev/null +++ b/internal/dns/resolved_test.go @@ -0,0 +1,93 @@ +package dns + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestResolvedProblem(t *testing.T) { + oldUnit, oldRuntime, oldNM, oldUp, oldManages := UnitDir, ResolvedRuntimeDir, NMConfDir, linkUp, nmManages + UnitDir, ResolvedRuntimeDir, NMConfDir = t.TempDir(), filepath.Join(t.TempDir(), "missing"), t.TempDir() + up, managed := true, false + linkUp = func() bool { return up } + 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) + } + + ResolvedRuntimeDir = t.TempDir() + 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(), []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") || 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) + } + if got := ResolvedFile(); got != filepath.Join(UnitDir, "flock-dns.service") { + t.Fatalf("file: %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 !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) + } +} 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/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") diff --git a/internal/ports/ports.go b/internal/ports/ports.go index 51466730..8f4e3504 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 @@ -141,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' diff --git a/linux/app/frontend/app.css b/linux/app/frontend/app.css new file mode 100644 index 00000000..702f2600 --- /dev/null +++ b/linux/app/frontend/app.css @@ -0,0 +1,172 @@ +/* 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: #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: #b4530f; + --bad: #dc2626; + --mono: "JetBrains Mono", "JetBrainsMono NF", ui-monospace, monospace; + color-scheme: light; +} +@media (prefers-color-scheme: dark) { + :root { + --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; + } +} + +* { box-sizing: border-box; } +html, body { height: 100%; margin: 0; } +body { + background: var(--bg); + color: var(--fg); + 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; + font-weight: 500; + color: var(--fg); + background: var(--bg-2); + 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: 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"], input[type="text"], select { + font: inherit; + color: var(--fg); + background: var(--bg-2); + 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; } +.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: 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); } +.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); } +.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; 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(--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; 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 new file mode 100644 index 00000000..3686d505 --- /dev/null +++ b/linux/app/frontend/app.js @@ -0,0 +1,94 @@ +// 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()); +} + +// 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(); +} + +// 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/dumps.html b/linux/app/frontend/dumps.html new file mode 100644 index 00000000..54421bbf --- /dev/null +++ b/linux/app/frontend/dumps.html @@ -0,0 +1,362 @@ + + + + + +Flock Dumps + + + + +
+
+ Dumps + + + + + + + +
+ +
+
+ + + + 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 00000000..798d6d9f Binary files /dev/null and b/linux/app/frontend/fonts/inter.woff2 differ diff --git a/linux/app/frontend/fonts/jetbrains-mono.woff2 b/linux/app/frontend/fonts/jetbrains-mono.woff2 new file mode 100644 index 00000000..cd5102a4 Binary files /dev/null and b/linux/app/frontend/fonts/jetbrains-mono.woff2 differ diff --git a/linux/app/frontend/icon.png b/linux/app/frontend/icon.png new file mode 100644 index 00000000..0e33c719 Binary files /dev/null and b/linux/app/frontend/icon.png differ diff --git a/linux/app/frontend/icons.js b/linux/app/frontend/icons.js new file mode 100644 index 00000000..c7d0382c --- /dev/null +++ b/linux/app/frontend/icons.js @@ -0,0 +1,33 @@ +// 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": "", + "download": "", + "eye": "", + "folder": "", + "hexagon": "", + "info-circle": "", + "lock": "", + "mail": "", + "pin": "", + "plus": "", + "server": "", + "settings": "", + "terminal-2": "", + "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/logs.html b/linux/app/frontend/logs.html new file mode 100644 index 00000000..17395a2c --- /dev/null +++ b/linux/app/frontend/logs.html @@ -0,0 +1,120 @@ + + + + + +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..f7de03cb --- /dev/null +++ b/linux/app/frontend/mail.html @@ -0,0 +1,150 @@ + + + + + +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..6091213f --- /dev/null +++ b/linux/app/frontend/panel.html @@ -0,0 +1,159 @@ + + + + + +Flock + + + + +
+ +
+

Flock

+
+
+
+ +
+
+ + + + +
+ + + + diff --git a/linux/app/frontend/settings.html b/linux/app/frontend/settings.html new file mode 100644 index 00000000..95ed12f1 --- /dev/null +++ b/linux/app/frontend/settings.html @@ -0,0 +1,118 @@ + + + + + +Flock Settings + + + + +
+ +
+

+ +
+
+
+ + + + diff --git a/linux/app/frontend/settings/about.js b/linux/app/frontend/settings/about.js new file mode 100644 index 00000000..4cec1973 --- /dev/null +++ b/linux/app/frontend/settings/about.js @@ -0,0 +1,41 @@ +// Settings → About: versions, the data folder, the docs, reporting an issue, and the license. +import { app, h, openURL } from "../app.js"; + +function field(label, ...content) { + return h("div", { class: "field" }, h("span", { class: "label" }, label), h("div", { class: "grow" }, ...content)); +} + +// A blank GitHub issue with the versions filled in, so reports say what they run on. +function issueURL(ctx) { + const body = `\n\n---\nDaemon: ${ctx.status?.version || "not running"}\nOS: Linux (${navigator.userAgent})\n`; + return "https://github.com/flock-php/flock/issues/new?body=" + encodeURIComponent(body); +} + +export default { + title: "About", + subtitle: "A native local development environment for Laravel.", + icon: "info-circle", + color: "#64748b", + render(ctx) { + const root = ctx.config.root; + const open = (label, url) => h("button", { onclick: () => openURL(url) }, label); + return h("div", {}, + h("div", { class: "row", style: "gap:20px; margin: 8px 0 20px" }, + h("img", { src: "icon.png", width: 72, height: 72, alt: "" }), + h("div", {}, h("div", { style: "font-size:24px; font-weight:650" }, "Flock"), + h("div", { class: "muted" }, "A native local development environment for Laravel."))), + h("div", { class: "group" }, + field("Version", h("span", { class: "mono selectable" }, ctx.status.version)), + field("Data folder", h("div", { class: "row" }, h("span", { class: "grow mono selectable ellipsis" }, root), + h("button", { onclick: () => app("folder?path=" + encodeURIComponent(root)) }, "Show"))), + field("Documentation", open("Open", "https://flockphp.com/docs")), + field("Found a bug?", h("div", { class: "row" }, h("span", { class: "grow hint" }, "Opens a new GitHub issue with your versions filled in."), + open("Report an issue", issueURL(ctx)))), + field("License", h("div", { class: "row" }, h("span", { class: "grow hint" }, "Copyright © Guava s.r.o. Licensed under the AGPL-3.0."), + open("View", "https://github.com/flock-php/flock/blob/main/LICENSE")))), + h("div", { class: "group" }, h("h3", {}, "Uninstall"), + field("Uninstall Flock", h("span", { class: "hint" }, "Run ", h("code", { class: "mono" }, "flock-php uninstall"), + " in a terminal, then remove the app with your package manager. Your sites, databases and settings are kept; add ", + h("code", { class: "mono" }, "--purge"), " to delete them too.")))); + }, +}; diff --git a/linux/app/frontend/settings/ai.js b/linux/app/frontend/settings/ai.js new file mode 100644 index 00000000..7bad9327 --- /dev/null +++ b/linux/app/frontend/settings/ai.js @@ -0,0 +1,57 @@ +// Settings → AI: registers Flock's MCP server with AI clients. It's added once and works in every +// project: the server finds the project the agent works in. +import { api, h } from "../app.js"; + +// GET /v1/mcp, loaded when the pane is first shown and after each change; the status poll doesn't +// carry it. +let mcp = null; +let working = null; +let loading = false; + +function field(label, ...content) { + return h("div", { class: "field" }, h("span", { class: "label" }, label), h("div", { class: "grow" }, ...content)); +} + +async function load(ctx) { + if (loading) return; + loading = true; + try { + mcp = await api("/v1/mcp"); + } finally { + loading = false; + } + ctx.rerender(); +} + +function row(ctx, c) { + let description = { registered: "Flock is added", available: "Installed" }[c.state] || "Not installed"; + if (c.problem) description += " — " + c.problem; + const add = c.state === "available"; + const button = (c.state === "registered" || add) && h("button", { disabled: working != null, onclick: () => ctx.act(async () => { + working = c.id; + ctx.rerender(); + try { + mcp = await api(`/v1/mcp/clients/${encodeURIComponent(c.id)}`, { method: add ? "POST" : "DELETE" }); + } finally { + working = null; + } + }) }, working === c.id ? "…" : add ? "Add" : "Remove"); + return field(c.name, h("div", { class: "row" }, h("span", { class: "grow muted", title: c.config || "" }, description), button)); +} + +export default { + title: "AI", + subtitle: "Let AI agents like Claude Code and Cursor work with Flock: your sites' URLs and PHP versions, logs, dumps and mail, and installing versions and services.", + icon: "brain", + color: "#8b5cf6", + render(ctx) { + if (!mcp) load(ctx).catch((err) => ctx.act(() => { throw err; })); + return h("div", {}, + h("div", { class: "group" }, h("h3", {}, "AI clients"), + ...(mcp ? mcp.clients.map((c) => row(ctx, c)) : [h("div", { class: "muted" }, "Loading…")]), + h("div", { class: "hint muted", style: "margin-top:6px" }, "Added once, Flock works in every project. Restart the client, or reload its MCP servers, after adding Flock.")), + h("div", { class: "group" }, h("h3", {}, "Other clients"), + h("pre", { class: "card mono selectable", style: "margin:0; padding:10px 12px; white-space:pre-wrap" }, mcp?.snippet || ""), + h("div", { class: "hint muted", style: "margin-top:6px" }, "Add this to the client's MCP settings."))); + }, +}; diff --git a/linux/app/frontend/settings/debugger.js b/linux/app/frontend/settings/debugger.js new file mode 100644 index 00000000..2d9336c0 --- /dev/null +++ b/linux/app/frontend/settings/debugger.js @@ -0,0 +1,46 @@ +// Settings → Debugger: Xdebug, loaded only for the requests that ask for it, and PhpStorm's +// breakpoints as a trigger. +import { api, h } from "../app.js"; +import { icon } from "../icons.js"; + +function field(label, ...content) { + return h("div", { class: "field" }, h("span", { class: "label" }, label), h("div", { class: "grow" }, ...content)); +} + +function checkbox(checked, onchange, disabled) { + const box = h("input", { type: "checkbox", checked, disabled }); + box.addEventListener("change", () => onchange(box.checked)); + return box; +} + +const settings = (ctx, body) => ctx.act(() => api("/v1/settings", { method: "POST", body })); + +export default { + title: "Debugger", + subtitle: "Step through requests and commands with Xdebug.", + icon: "bug", + color: "#22c55e", + render(ctx) { + const st = ctx.status; + const debugging = st.xdebug && st.xdebug_breakpoints ? (st.sites || []).filter((s) => s.debugging) : []; + + return h("div", {}, + h("div", { class: "group" }, + field("Xdebug requests", h("label", { class: "row" }, checkbox(st.xdebug, (on) => settings(ctx, { xdebug: on })), "Detect Xdebug requests"), + h("div", { class: "hint" }, "Xdebug loads only for requests that ask for it, so everything else stays fast.")), + field("PhpStorm", h("label", { class: "row" }, + checkbox(st.xdebug_breakpoints, (on) => settings(ctx, { xdebug_breakpoints: on }), !st.xdebug), "Detect PhpStorm breakpoints"), + h("div", { class: "hint" }, "While a project has breakpoints in PhpStorm, every request to its site is debugged."))), + debugging.length > 0 && h("div", { class: "group" }, h("h3", {}, "Debugging now"), + ...debugging.map((s) => field(h("span", { class: "row" }, icon("bug", 14), s.domain), "Breakpoints in PhpStorm"))), + h("div", { class: "group" }, h("h3", {}, "Connect your IDE"), + field("Client port", h("span", { class: "mono" }, "9003")), + field("Mode", h("span", { class: "mono" }, "debug, develop")), + field("Start a session", h("span", { class: "hint" }, "Set a breakpoint in PhpStorm and listen for debug connections, use the Xdebug browser extension, or add ?XDEBUG_TRIGGER=1 to the URL. On the command line, run `flock-php debug artisan …`."))), + h("div", { class: "group" }, h("h3", {}, "Availability"), + ...(ctx.config.php || []).map((p) => field(`PHP ${p.version}`, p.xdebug_available + ? h("span", { style: "color: var(--ok)" }, "Available") + : h("span", { class: "muted" }, "Not in this build"))), + !(ctx.config.php || []).length && h("div", { class: "hint muted" }, "No PHP version installed yet."))); + }, +}; diff --git a/linux/app/frontend/settings/dumps.js b/linux/app/frontend/settings/dumps.js new file mode 100644 index 00000000..2bacc03a --- /dev/null +++ b/linux/app/frontend/settings/dumps.js @@ -0,0 +1,57 @@ +// Settings → Dumps: capturing dump() and dd() output and the events of the PHP agent (queries, +// logs, views, jobs, requests), and which editor a dump's file opens in. +import { api, h, openWindow } from "../app.js"; + +// The kinds the daemon captures (internal/capture.Kinds), in its order. +const kinds = [["query", "Queries"], ["log", "Logs"], ["view", "Views"], ["job", "Jobs"], ["request", "Requests"]]; +// The editors a dump's file and line open in; the Dumps window reads the same key. +const editors = [["phpstorm", "PhpStorm"], ["vscode", "VS Code"], ["cursor", "Cursor"], ["zed", "Zed"], ["none", "Don't link"]]; +export const editorKey = "dumps.editor"; + +function field(label, ...content) { + return h("div", { class: "field" }, h("span", { class: "label" }, label), h("div", { class: "grow" }, ...content)); +} + +function checkbox(checked, onchange, disabled) { + const box = h("input", { type: "checkbox", checked, disabled }); + box.addEventListener("change", () => onchange(box.checked)); + return box; +} + +const settings = (ctx, body) => ctx.act(() => api("/v1/settings", { method: "POST", body })); + +export default { + title: "Dumps", + subtitle: "Send dump() and dd() output, queries, logs, views and jobs to Flock instead of the page or terminal — no changes to your projects.", + icon: "code", + color: "#f59e0b", + render(ctx) { + const st = ctx.status; + const capturing = st.dumps; + const off = st.capture_off || []; + const unsupported = st.capture_unsupported || []; + + let editor = "phpstorm"; + try { editor = localStorage.getItem(editorKey) || editor; } catch {} + const select = h("select", {}, ...editors.map(([id, name]) => h("option", { value: id, selected: id === editor }, name))); + select.addEventListener("change", () => { try { localStorage.setItem(editorKey, select.value); } catch {} }); + + return h("div", {}, + h("div", { class: "group" }, + field("Capture", h("label", { class: "row" }, checkbox(capturing, (on) => settings(ctx, { dumps: on })), "Capture dumps and events"), + h("div", { class: "hint" }, "Turn it off to see dumps in the page again.")), + field("Captured", h("div", { class: "row" }, + h("span", { class: "grow" }, `${st.dump_count || 0} dumps, ${st.event_count || 0} events`), + h("button", { onclick: () => openWindow("dumps") }, "Open Dumps")))), + h("div", { class: "group" }, h("h3", {}, "While capturing"), + ...kinds.map(([kind, name]) => field(name, h("label", { class: "row" }, + checkbox(!off.includes(kind), (on) => settings(ctx, { capture_off: on ? off.filter((k) => k !== kind) : [...off, kind] }), !capturing), + `Capture ${name.toLowerCase()}`))), + field("dump() in the page", h("label", { class: "row" }, + checkbox(st.dumps_in_page, (on) => settings(ctx, { dumps_in_page: on }), !capturing), "Show dump() output in the page too")), + h("div", { class: "hint muted", style: "margin-top:6px" }, "dd() always shows in the page too, as it ends the request." + + (unsupported.length ? ` PHP ${unsupported.join(", ")} only ${unsupported.length === 1 ? "captures" : "capture"} dumps: ${unsupported.length === 1 ? "its build has" : "their builds have"} no agent extension yet.` : ""))), + h("div", { class: "group" }, h("h3", {}, "Sources"), + field("Open files in", select, h("div", { class: "hint" }, "Used when clicking a dump's file and line.")))); + }, +}; diff --git a/linux/app/frontend/settings/general.js b/linux/app/frontend/settings/general.js new file mode 100644 index 00000000..7d8bedd1 --- /dev/null +++ b/linux/app/frontend/settings/general.js @@ -0,0 +1,65 @@ +// 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", + icon: "settings", + color: "#64748b", + 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/mail.js b/linux/app/frontend/settings/mail.js new file mode 100644 index 00000000..7dfdba13 --- /dev/null +++ b/linux/app/frontend/settings/mail.js @@ -0,0 +1,36 @@ +// Settings → Mail: the SMTP server that catches every email, and the .env lines that point an app +// at it. +import { api, h, openWindow } from "../app.js"; + +function field(label, ...content) { + return h("div", { class: "field" }, h("span", { class: "label" }, label), h("div", { class: "grow" }, ...content)); +} + +export default { + title: "Mail", + subtitle: "Flock catches every email your apps send — nothing leaves your computer.", + icon: "mail", + color: "#0ea5e9", + render(ctx) { + const st = ctx.status; + const status = h("span", {}, "…"); + const env = h("pre", { class: "card mono selectable", style: "margin:0; padding:10px 12px; white-space:pre-wrap" }); + // GET /v1/services carries the mail service's state and its .env snippet. + api("/v1/services").then((services) => { + const mail = services.find((s) => s.id === "mail"); + status.textContent = mail?.running ? "Running" : mail?.problem || "Stopped"; + env.textContent = mail?.env || ""; + }).catch((err) => { status.textContent = err.message; }); + + return h("div", {}, + h("div", { class: "group" }, h("h3", {}, "SMTP server"), + field("Host", h("span", { class: "mono" }, "127.0.0.1")), + field("Port", h("span", { class: "mono" }, ctx.config.ports?.smtp || "—")), + field("Status", status)), + h("div", { class: "group" }, h("h3", {}, "Environment variables"), env, + h("div", { class: "hint muted", style: "margin-top:6px" }, "MAIL_USERNAME picks the inbox, so every project gets its own.")), + h("div", { class: "group" }, + field("Caught emails", h("div", { class: "row" }, h("span", { class: "grow" }, String(st.mail_count || 0)), + h("button", { onclick: () => openWindow("mail") }, "Open Mail"))))); + }, +}; diff --git a/linux/app/frontend/settings/node.js b/linux/app/frontend/settings/node.js new file mode 100644 index 00000000..ef3aeb4d --- /dev/null +++ b/linux/app/frontend/settings/node.js @@ -0,0 +1,159 @@ +// Settings → Node: the Node.js majors from the package manifest, like the PHP pane: install, update, +// 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, ask, h, takeLink } from "../app.js"; +import { icon } from "../icons.js"; + +let versions = null; // GET /v1/node/versions +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; + +const css = ` + .versions { display: flex; flex-direction: column; gap: 6px; } + .versions > .head { display: flex; align-items: center; gap: 8px; margin: 0 1px 4px; } + .versions > .head strong { flex: 1; } + .version { display: flex; align-items: center; gap: 14px; padding: 10px 14px; } + .version > .icon { color: var(--muted); } + .version.installed > .icon { color: var(--accent); } + .version .title { display: flex; align-items: center; gap: 8px; } + .version .title strong { font-weight: 650; } + .version .detail { font-size: 12px; color: var(--muted); } + .version .detail .accent { color: var(--accent); } + .footnote { margin: 10px 4px; color: var(--muted); font-size: 12px; } +`; + +async function load(ctx, path = "/v1/node/versions", method = "GET") { + try { + versions = await api(path, { method }); + error = ""; + } catch (err) { + error = err.message; + } + loadedAt = Date.now(); + if (root?.isConnected) ctx.rerender(); +} + +async function run(ctx, major, step, fn) { + busy.set(major, step); + ctx.rerender(); + await ctx.act(async () => { versions = (await fn()) || versions; }); + busy.delete(major); + 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; + const step = busy.get(m) ?? (m === defaultInstalling && !installed ? `Downloading Node.js ${v.latest}…` : null); + const path = `/v1/node/versions/${m}`; + const install = (label, primary, title) => h("button", { class: primary ? "primary" : "", title, + onclick: () => run(ctx, m, `Downloading Node.js ${v.latest}…`, () => api(`${path}/install`, { method: "POST" })) }, label); + + const actions = []; + if (!installed) { + actions.push(install("Install", false, `Downloads Node.js ${v.latest}`)); + } else { + if (v.update) actions.push(install(`Update to ${v.latest}`, true, `Downloads Node.js ${v.latest}, then removes ${v.installed}. Global packages stay.`)); + if (v.npm_latest) { + actions.push(h("button", { title: `Installs npm ${v.npm_latest}, the newest npm that supports Node.js ${v.installed}, for Node ${m}`, + onclick: () => run(ctx, m, `Updating npm to ${v.npm_latest}…`, () => api(`${path}/npm`, { method: "POST" })) }, "Update npm")); + } + if (!v.default) { + actions.push(h("button", { onclick: () => run(ctx, m, "Making it the default…", async () => { + await api("/v1/settings", { method: "POST", body: { default_node: m } }); + return api("/v1/node/versions"); + }) }, "Make Default")); + } + actions.push(h("button", { disabled: v.default, title: v.default ? "Make another version the default first" : "", + onclick: () => confirm(`Uninstall Node ${m}? Its global npm packages are removed too. Projects and sites that use Node ${m} fall back to the default version until you install it again.`) && + run(ctx, m, "Uninstalling…", () => api(path, { method: "DELETE" })) }, "Uninstall…")); + } + if (step) actions.forEach((b) => { b.disabled = true; }); + + const status = v.update ? h("span", { class: "accent" }, "Update available") : installed ? "Up to date" : "Not installed"; + return h("div", { class: "card" }, h("div", { class: "version" + (installed ? " installed" : "") }, + icon("brand-nodejs", 20), + h("div", { class: "grow" }, + h("div", { class: "title" }, h("strong", {}, `Node ${m}`), + h("span", { class: "muted" }, v.update ? `${v.installed} → ${v.latest}` : v.installed || v.latest), + v.default && h("span", { class: "badge" }, "Default")), + h("div", { class: "detail" }, step || [status, + v.npm && ` · npm ${v.npm}`, + v.npm_latest && h("span", { class: "accent" }, ` (npm ${v.npm_latest} available)`)])), + h("div", { class: "row" }, ...actions))); +} + +export default { + title: "Node", + subtitle: "node, npm and npx follow the project's .nvmrc / .node-version, then the site's version, then the default.", + icon: "brand-nodejs", + color: "#16a34a", + render(ctx) { + // The first run installs the default Node.js; only the status shows that. Once it's done + // (or failed), the versions show the result. + const node = ctx.status.defaults?.node; + const defaultInstalling = node?.state === "installing" && node.version ? node.version : null; + const changed = installingDefault !== defaultInstalling; + installingDefault = defaultInstalling; + + root = h("div", {}, h("style", {}, css)); + // First shown, back on this pane after a while, or the default's install ended: load again. + if (changed || Date.now() - loadedAt > 30000) load(ctx); + if (!versions) { + 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; + ctx.rerender(); + await load(ctx, "/v1/node/check", "POST"); + checking = false; + ctx.rerender(); + } }, checking ? "Checking…" : "Check now"); + + root.append( + error && h("div", { class: "error" }, error), + h("div", { class: "versions" }, + h("div", { class: "head" }, h("strong", {}, "Versions"), + h("span", { class: "muted" }, versions.checked_at ? `Checked ${ago(versions.checked_at)}` : "Not checked yet"), check), + ...versions.versions.map((v) => row(ctx, v, defaultInstalling)), + !versions.versions.length && h("div", { class: "card empty" }, "No Node.js versions to show yet."), + versions.check_error && h("div", { style: "color: var(--warn); font-size: 12px" }, `⚠ Couldn't check for updates: ${versions.check_error}`)), + h("p", { class: "footnote" }, "Official Node.js builds, the newest release of each major. A project that pins a version in .nvmrc gets that major offered when you run node, npm or npx there. ", + "Global packages (npm install -g) belong to a Node line and survive its updates; those of the default version are on your PATH.")); + return root; + }, +}; diff --git a/linux/app/frontend/settings/php.js b/linux/app/frontend/settings/php.js new file mode 100644 index 00000000..7c715920 --- /dev/null +++ b/linux/app/frontend/settings/php.js @@ -0,0 +1,255 @@ +// 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, ask, h, takeLink } from "../app.js"; +import { icon } from "../icons.js"; + +let versions = null; // GET /v1/php/versions +let franken = null; // GET /v1/frankenphp; null when this platform has no FrankenPHP +let error = ""; +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; +let root = null; + +const css = ` + .versions { display: flex; flex-direction: column; gap: 6px; margin-bottom: 24px; } + .versions > .head { display: flex; align-items: center; gap: 8px; margin: 0 1px 4px; } + .versions > .head strong { flex: 1; } + .version { display: flex; align-items: center; gap: 14px; padding: 10px 14px; } + .version > .icon { color: var(--muted); } + .version.installed > .icon { color: var(--accent); } + .version .title { display: flex; align-items: center; gap: 8px; } + .version .title strong { font-weight: 650; } + .version .detail { font-size: 12px; color: var(--muted); } + .version .detail.accent { color: var(--accent); } + .version .detail.warn { color: var(--warn); } + .version .detail.bad { color: var(--bad); } + .tag.warn { background: color-mix(in srgb, var(--warn) 16%, transparent); color: var(--warn); } + .tag.bad { background: color-mix(in srgb, var(--bad) 16%, transparent); color: var(--bad); } + pre.log { margin: 0; padding: 10px 14px; max-height: 300px; overflow: auto; border-top: 1px solid var(--line); font: 11px/1.5 var(--mono); white-space: pre-wrap; } +`; + +const mb = (bytes) => (bytes > 0 ? ` (${Math.round(bytes / 1e6)} MB)` : ""); + +async function load(ctx) { + try { + [versions, franken] = await Promise.all([ + api("/v1/php/versions"), + api("/v1/frankenphp").catch(() => null), // 501 where FrankenPHP isn't published + ]); + if (logOf) log = await api(logPath(logOf)).catch((e) => e.message); + error = ""; + } catch (err) { + error = err.message; + } + loadedAt = Date.now(); + if (root?.isConnected) ctx.rerender(); +} + +const logPath = (key) => { + const [kind, minor] = key.split(":"); + return kind === "php" ? `/v1/php/versions/${minor}/log` : `/v1/frankenphp/${minor}/log`; +}; + +// The next load: in 2 s while something downloads (or a log is shown), else in 30 s. The window's +// own poll re-renders more often than that, so a pending load is only ever brought forward. +function schedule(ctx) { + const running = [...versions.versions, ...(franken?.builds || [])].some((v) => v.job?.state === "running"); + const ms = running || logOf ? 2000 : 30000; + if (timer && Date.now() + ms >= dueAt) return; + clearTimeout(timer); + dueAt = Date.now() + ms; + timer = setTimeout(() => { + timer = 0; + if (root?.isConnected) load(ctx); + }, ms); +} + +// Runs a request for one row: its buttons are disabled until the daemon answers. +async function run(ctx, key, fn) { + busy.add(key); + ctx.rerender(); + await ctx.act(fn); + busy.delete(key); + 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; +} + +// The line under a row's name: a running or failed job, a withdrawn build, or the state. +function detail(v, idle) { + if (v.job?.state === "running") return h("div", { class: "detail" }, jobText(v.job)); + if (v.job?.state === "failed") return h("div", { class: "detail bad", title: v.job.error }, v.job.error || "The download failed."); + if (v.yanked) { + return h("div", { class: "detail warn", title: "This build was withdrawn. Flock replaces it as soon as a fixed build is published." }, + `Withdrawn: ${v.yanked}${v.update ? " — update to replace it" : ""}`); + } + return idle; +} + +function card(installed, iconName, title, sub, actions, key) { + const shown = logOf === key; + return h("div", { class: "card" }, + h("div", { class: "version" + (installed ? " installed" : "") }, + icon(iconName, 20), + h("div", { class: "grow" }, title, sub), + h("div", { class: "row" }, busy.has(key) && h("span", { class: "muted" }, "…"), ...actions.map((b) => { + if (busy.has(key)) b.disabled = true; + return b; + }))), + shown && h("pre", { class: "log selectable" }, log.split("\n").slice(-400).join("\n") || "No log yet.")); +} + +function logButton(ctx, key) { + return h("button", { class: "link", onclick: () => { + logOf = logOf === key ? null : key; + log = "Loading…"; + ctx.rerender(); + if (logOf) load(ctx); + } }, logOf === key ? "Hide Log" : "Show Log"); +} + +function phpRow(ctx, v) { + const m = v.minor; + const key = "php:" + m; + const path = `/v1/php/versions/${m}`; + const set = (p) => run(ctx, key, async () => { versions = await p(); }); + const install = (label, primary) => h("button", { class: primary ? "primary" : "", title: `Downloads PHP ${v.latest}${mb(v.size)}`, + onclick: () => set(() => api(`${path}/install`, { method: "POST" })) }, label); + const installed = !!v.installed; + const actions = []; + + if (v.job?.state === "running") { + actions.push(logButton(ctx, key), h("button", { onclick: () => set(() => api(`${path}/cancel`, { method: "POST" })) }, "Cancel")); + } else if (v.job?.state === "failed") { + actions.push(logButton(ctx, key), install("Retry"), h("button", { onclick: () => set(() => api(`${path}/job`, { method: "DELETE" })) }, "Dismiss")); + } else if (installed) { + if (v.update) actions.push(install(v.latest === v.installed ? "Update" : `Update to ${v.latest}`, true)); + if (!v.default) { + actions.push(h("button", { onclick: () => run(ctx, key, () => api("/v1/settings", { method: "POST", body: { default_php: m } })) }, "Make Default")); + } + const ini = (ctx.config.php || []).find((p) => p.version === m)?.ini; + if (ini) actions.push(h("button", { title: ini, onclick: () => app("folder?path=" + encodeURIComponent(ini)) }, "Open php.ini")); + actions.push(logButton(ctx, key)); + actions.push(h("button", { disabled: v.default, title: v.default ? "Make another version the default first" : "", + onclick: () => confirm(`Uninstall PHP ${m}? Its php.ini is removed too. Sites pinned to PHP ${m} stop working until you pick another version.`) && + set(() => api(path, { method: "DELETE" })) }, "Uninstall…")); + } else { + actions.push(install("Install")); + } + + const status = { installed: ["Up to date", ""], update: [v.latest === v.installed ? "A new build is available" : "Update available", "accent"], available: ["Not installed", ""] }[v.status] || ["", ""]; + const version = v.update && v.latest !== v.installed ? `${v.installed} → ${v.latest}` : v.installed || v.latest; + return card(installed, "brand-php", h("div", { class: "title" }, + h("strong", {}, `PHP ${m}`), h("span", { class: "muted" }, version), + v.default && h("span", { class: "badge" }, "Default"), + v.prerelease && h("span", { class: "tag warn", title: `PHP ${v.latest} isn't released yet — it may not work. Offered on the beta channel.` }, "Pre-release"), + installed && v.xdebug && h("span", { class: "tag bad" }, "Xdebug")), + detail(v, h("div", { class: "detail " + status[1] }, status[0])), actions, key); +} + +function frankenRow(ctx, b) { + const m = b.php; + const key = "frankenphp:" + m; + const path = `/v1/frankenphp/${m}`; + const set = (p) => run(ctx, key, async () => { franken = await p(); }); + const install = (label, primary) => h("button", { class: primary ? "primary" : "", title: `Downloads FrankenPHP for PHP ${b.latest}${mb(b.size)}`, + onclick: () => set(() => api(`${path}/install`, { method: "POST" })) }, label); + const installed = !!b.installed; + const actions = []; + + if (b.job?.state === "running") { + actions.push(h("button", { onclick: () => set(() => api(`${path}/cancel`, { method: "POST" })) }, "Cancel")); + } else if (b.job?.state === "failed") { + actions.push(logButton(ctx, key), install("Retry"), h("button", { onclick: () => set(() => api(`${path}/job`, { method: "DELETE" })) }, "Dismiss")); + } else { + // Installing ahead makes switching a site to FrankenPHP immediate (else PHP-CGI serves it meanwhile). + if (!installed && b.latest) actions.push(install("Install")); + if (b.update) actions.push(install(b.latest === b.php_version ? "Update" : `Update to PHP ${b.latest}`, true)); + if (installed) actions.push(h("button", { onclick: () => set(() => api(path, { method: "DELETE" })) }, "Remove")); + } + + return card(installed, "server", h("div", { class: "title" }, + h("strong", {}, `PHP ${m}`), + b.prerelease && h("span", { class: "tag warn", title: `PHP ${b.latest} isn't released yet — it may not work. Offered on the beta channel.` }, "Pre-release")), + detail(b, h("div", { class: "detail" }, installed ? `FrankenPHP ${b.installed} (PHP ${b.php_version})` : "Not installed · downloads by itself when a site uses it")), + actions, key); +} + +export default { + title: "PHP", + subtitle: "Every installed version runs its own pool of PHP workers. Sites use the default unless pinned to a version.", + icon: "brand-php", + color: "#6366f1", + render(ctx) { + root = h("div", {}, h("style", {}, css)); + if (!versions) { + root.append(h("div", { class: "empty" }, error || "Loading PHP versions…")); + } + // First shown, or back on this pane after a while (or a failed load): load again. + 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; + ctx.rerender(); + await ctx.act(async () => { + [versions, franken] = await Promise.all([api("/v1/php/check", { method: "POST" }), franken && api("/v1/frankenphp/check", { method: "POST" })]); + }); + checking = false; + ctx.rerender(); + } }, checking ? "Checking…" : "Check now"); + + root.append( + error && h("div", { class: "error" }, error), + // Named after the server, like the FrankenPHP section below and a site's server picker. + h("div", { class: "versions" }, + h("div", { class: "head" }, h("strong", {}, "PHP-CGI"), + h("span", { class: "muted" }, versions.checked_at ? `Checked ${ago(versions.checked_at)}` : "Not checked yet"), check), + ...versions.versions.map((v) => phpRow(ctx, v)), + !versions.versions.length && h("div", { class: "card empty" }, "No PHP versions to show yet."), + versions.check_error && h("div", { class: "detail", style: "color: var(--warn)" }, "⚠ " + versions.check_error)), + franken?.available && h("div", { class: "versions" }, + h("div", { class: "head" }, h("strong", {}, "FrankenPHP")), + ...franken.builds.map((b) => frankenRow(ctx, b)), + !franken.builds.length && h("div", { class: "card empty" }, "No FrankenPHP builds to show yet."), + franken.check_error && h("div", { class: "detail", style: "color: var(--warn)" }, "⚠ " + franken.check_error))); + return root; + }, +}; diff --git a/linux/app/frontend/settings/services-installs.js b/linux/app/frontend/settings/services-installs.js new file mode 100644 index 00000000..033e9f89 --- /dev/null +++ b/linux/app/frontend/settings/services-installs.js @@ -0,0 +1,339 @@ +// 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, 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; +let loaded = ""; + +export const entry = (id) => catalog?.services?.find((e) => e.id === id); +export const pgWorking = () => !!pending.postgres || !!pg?.majors?.some((m) => m.busy); +export const catalogWorking = (id) => !!pending[id] || entry(id)?.job?.state === "running"; +export const BUSY = { installing: "Installing…", updating: "Updating…", switching: "Switching…", uninstalling: "Uninstalling…" }; +const PHASE = { downloading: "Downloading…", installing: "Installing…" }; +export const phase = (job) => PHASE[job.phase] || (job.action === "update" ? "Updating…" : "Working…"); +const cap = (s) => s.charAt(0).toUpperCase() + s.slice(1); + +export async function load(ctx) { + const [s, p, c] = await Promise.all([ + api("/v1/services"), + api("/v1/postgres/versions").catch(() => null), + api("/v1/catalog").catch(() => null), + ]); + services = s; + pg = p; + catalog = c; + const key = JSON.stringify([s, p, c]); + if (key !== loaded) { + loaded = key; + ctx.rerender(); + } +} + +// 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; + ctx.rerender(); + await ctx.act(fn); + delete pending[id]; + await load(ctx).catch(() => {}); + ctx.rerender(); +} + +// 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…"); + const tick = async () => { + let text; + try { + text = (await api(`/v1/catalog/${encodeURIComponent(e.id)}/log`)).trimEnd() || "No log yet."; + } catch (err) { + text = err.message; + } + if (pre.textContent !== text) { + pre.textContent = text; + pre.scrollTop = pre.scrollHeight; + } + }; + const follow = setInterval(() => catalogWorking(e.id) && tick(), 2000); + const d = h("dialog", { class: "card ask log" }, h("h3", {}, `${e.name} install log`), pre, + h("div", { class: "row", style: "justify-content: flex-end; margin-top: 14px" }, h("button", { onclick: () => d.close() }, "Done"))); + d.addEventListener("close", () => { clearInterval(follow); d.remove(); }); + document.body.append(d); + d.showModal(); + tick(); +} + +function checkFooter(ctx, checkedAt, checkError, path) { + return h("div", { class: "small muted" }, + h("div", { class: "row" }, h("span", { class: "grow" }, checkedAt ? `Last checked ${ago(checkedAt)}` : "Not checked yet"), + checking ? h("span", {}, "Checking…") : h("button", { class: "link", onclick: async () => { + checking = true; + ctx.rerender(); + await ctx.act(() => api(path, { method: "POST" })); + checking = false; + await load(ctx).catch(() => {}); + ctx.rerender(); + } }, "Check for Updates")), + checkError && h("div", { class: "warn" }, `⚠ Couldn't check: ${checkError}`)); +} + +// Installing what isn't installed yet, the same for PostgreSQL and the catalog services: the +// version (when there is a choice) and the port, then Install. +function installCard(id, name, description, options, preferred, port, portNote, estimate, install) { + const p = (picked[id] ||= {}); + const select = h("select", { disabled: options.length < 2 }, + ...options.map((o) => h("option", { value: o.id, selected: o.id === (p.line ?? preferred) }, o.label))); + const portInput = h("input", { type: "text", inputmode: "numeric", value: p.port ?? port, style: "width: 90px" }); + const button = h("button", { class: "primary" }); + const label = () => { + const latest = options.find((o) => o.id === select.value)?.latest; + button.textContent = latest ? `Install ${name} ${latest}` : `Install ${name}`; + }; + label(); + select.addEventListener("change", () => { p.line = select.value; label(); }); + portInput.addEventListener("input", () => { p.port = portInput.value; }); + button.addEventListener("click", () => { + const n = parseInt(portInput.value, 10); + install(select.value || null, n >= 1024 && n <= 65535 ? n : null); + }); + return h("div", { class: "inner hero" }, + h("strong", {}, `Install ${name}`), h("div", { class: "muted" }, description), + h("div", { class: "row", style: "margin-top: 6px" }, h("label", { class: "muted" }, "Version"), select, h("label", { class: "muted" }, "Port"), portInput), + portNote && h("div", { class: "small muted" }, portNote), + button, + estimate && h("div", { class: "small muted" }, cap(estimate))); +} + +// PostgreSQL + +export function postgresDetail(ctx) { + if (!pg) return [h("div", { class: "muted" }, "Loading PostgreSQL versions…")]; + const majors = pg.majors || []; + const installed = majors.some((m) => m.installed); + const out = []; + if (!installed && pgWorking()) { + const m = majors.find((x) => x.busy) || {}; + out.push(h("div", { class: "inner" }, + h("strong", {}, `${m.busy === "uninstalling" ? "Uninstalling" : "Installing"} PostgreSQL ${m.latest || m.major || ""}…`), + h("progress", {}), + h("div", { class: "small muted" }, "Downloading the server and creating its database cluster. This takes a minute or two; you can close this window meanwhile."))); + } else if (!installed) { + if (!majors.length) { + out.push(h("div", { class: "inner muted" }, "Flock offers no PostgreSQL versions for this computer.")); + } else { + const kept = majors.filter((m) => m.has_data).map((m) => m.major); + out.push(installCard("postgres", "PostgreSQL", + "The database for your apps. You can add other versions later; one runs at a time, and each keeps its own databases.", + majors.map((m) => ({ id: m.major, latest: m.latest, label: (m.latest ? `${m.major} (${m.latest})` : m.major) + (m.has_data ? " — your databases" : "") })), + majors[0].major, pg.port, null, "", + (major, port) => work(ctx, "postgres", "Installing…", async () => { + // The port belongs to PostgreSQL, not to a version, so it is set first. + if (port && port !== pg.port) await api("/v1/services/postgres", { method: "PATCH", body: { port } }); + await api(`/v1/postgres/versions/${major}/install`, { method: "POST" }); + }))); + if (kept.length) out.push(h("div", { class: "small muted" }, `Databases of PostgreSQL ${kept.join(", ")} are still here — installing that version again picks them up.`)); + } + } else { + out.push(activeCard(), h("div", {}, h("strong", {}, "Versions"), + h("div", { class: "inner versions", style: "padding: 0 14px" }, ...majors.map((m) => majorRow(ctx, m))), + h("div", { class: "small muted", style: "margin-top: 6px" }, "Installed versions stay side by side. One runs at a time; each keeps its own databases, and updates within a version keep them."))); + } + out.push(checkFooter(ctx, pg.checked_at, pg.check_error, "/v1/postgres/check"), + h("div", { class: "small muted" }, "psql, pg_dump and the other command-line tools come with PostgreSQL. In a terminal, Flock uses those of the newest installed version, which work with every version above and with remote servers.")); + return out; +} + +function activeCard() { + const active = pg.majors.find((m) => m.major === pg.active && pg.active); + if (!active) return h("div", { class: "inner muted" }, "No version is active. Switch to an installed version below to start PostgreSQL."); + const state = pg.running ? "Running" : pg.enabled ? "Not running" : "Stopped"; + return h("div", { class: "inner" }, + h("div", { class: "small muted" }, "Active version"), + h("div", { style: "font-size: 16px; font-weight: 650" }, `PostgreSQL ${active.installed}`), + h("div", { class: "row small muted" }, h("span", { class: "dot" + (pg.running ? " ok" : "") }), + `${state} · Port ${pg.port}` + (active.has_data ? ` · ${size(active.data_size)} of data` : "")), + active.busy && h("div", { class: "small muted" }, BUSY[active.busy] || "Working…"), + !active.busy && active.update && h("div", { class: "small accent" }, + `${active.latest === active.installed ? `A new build of ${active.latest}` : active.latest} is available — update it below; your databases stay.`), + active.yanked && h("div", { class: "small warn", title: "Flock replaces it as soon as a fixed build is published." }, `⚠ This build was withdrawn: ${active.yanked}`)); +} + +function majorRow(ctx, m) { + const facts = [m.installed || "Not installed"]; + if (m.update) facts.push(m.latest === m.installed ? "new build available" : `${m.latest} available`); + if (m.yanked) facts.push(`withdrawn: ${m.yanked}`); + if (m.prerelease) facts.push(`${m.latest} is a pre-release`); + if (m.has_data) facts.push(m.installed ? `${size(m.data_size)} of data` : `databases kept (${size(m.data_size)})`); + + const busy = m.busy ? BUSY[m.busy] || "Working…" : null; + const run = (step, fn) => work(ctx, "postgres", step, fn); + const path = `/v1/postgres/versions/${m.major}`; + let actions; + if (busy) { + actions = [h("span", { class: "small muted" }, busy)]; + } else if (!m.installed) { + actions = [h("button", { title: (m.latest ? `Downloads PostgreSQL ${m.latest}.` : "Downloads the newest release.") + + (pg.active ? ` PostgreSQL ${pg.active} stays active until you switch.` : "") + (m.has_data ? " Its databases are still here and come back." : ""), + onclick: () => run("Downloading and installing…", () => api(`${path}/install`, { method: "POST" })) }, "Install")]; + } else { + actions = [ + m.update && h("button", { title: `Installs ${m.latest} in place of ${m.installed}. Your databases stay.`, + onclick: () => run("Updating…", () => api(`${path}/update`, { method: "POST" })) }, `Update to ${m.latest}`), + !m.active && h("button", { onclick: async () => { + if (pg.active && !await ask(`Switch to PostgreSQL ${m.major}?`, + `PostgreSQL ${pg.active} stops; its databases stay but aren't available until you switch back. ${m.major} uses its own databases.` + + (m.has_data ? "" : " It starts with a new, empty database cluster."), "Switch")) return; + run("Switching…", () => api(`${path}/activate`, { method: "POST" })); + } }, "Switch…"), + h("button", { onclick: async () => { + const next = pg.majors.find((x) => x.installed && x.major !== m.major); + const text = (!m.active ? "" + : next ? `PostgreSQL ${m.major} stops and PostgreSQL ${next.major} becomes the active version. ` + : `PostgreSQL ${m.major} stops; no PostgreSQL runs until you install a version again. `) + + (m.has_data ? `Its databases stay unless you delete them below; installing ${m.major} again picks them up.` : "It holds no databases."); + const answer = await ask(`Uninstall PostgreSQL ${m.major}?`, text, "Uninstall", { danger: true, + check: m.has_data && { label: `Also delete its databases (${size(m.data_size)})`, warning: "Deleted databases can't be recovered." } }); + if (answer) run("Uninstalling…", () => api(path + (answer.checked ? "?delete_data=1" : ""), { method: "DELETE" })); + } }, "Uninstall…"), + ]; + } + // One PostgreSQL operation at a time. + if (pgWorking()) for (const a of actions) if (a && a.tagName === "BUTTON") a.disabled = true; + return h("div", {}, + h("div", { class: "grow" }, + h("div", { class: "row" }, h(m.installed ? "strong" : "span", {}, `PostgreSQL ${m.major}`), m.active && h("span", { class: "tag" }, "Active")), + h("div", { class: "small " + (m.update ? "accent" : "muted") }, facts.join(" · "))), + ...actions); +} + +// Catalog services + +export function catalogDetail(ctx, s) { + if (!catalog) return [h("div", { class: "muted" }, "Loading…")]; + const e = entry(s.id); + if (!e) return [h("div", { class: "muted" }, `This Flock daemon doesn't offer ${s.name}. Update Flock, then try again.`)]; + const out = []; + if (e.job) out.push(jobCard(ctx, e, e.job)); + const lines = e.lines || []; + if (!lines.some((l) => l.installed)) { + if (e.job?.state !== "running" && !pending[e.id]) { + out.push(installCard(e.id, e.name, e.description || "Runs on 127.0.0.1 for your sites.", + lines.map((l) => ({ id: l.id, latest: l.latest, label: l.latest ? `${l.label || l.id} (${l.latest})` : l.label || l.id })), + (lines.find((l) => l.active) || lines[0])?.id, e.port, + e.port !== e.default_port ? `Port ${e.default_port} is taken, so Flock suggests ${e.port}.` : null, e.estimate, + (line, port) => work(ctx, e.id, "Starting…", () => api(`/v1/catalog/${e.id}/install`, { method: "POST", body: { line: line || "", port: port || 0 } })))); + } + } else { + out.push(h("div", {}, h("strong", {}, "Versions"), + h("div", { class: "inner versions", style: "padding: 0 14px" }, ...lines.map((l) => lineRow(ctx, e, l))), + h("div", { class: "small muted", style: "margin-top: 6px" }, lines.length > 1 + ? "Installed versions stay side by side. One runs at a time; each keeps its own data, and updates within a version keep it." + : "Updates keep your data."))); + } + out.push(checkFooter(ctx, catalog.checked_at, catalog.check_error, "/v1/catalog/check")); + return out; +} + +function jobCard(ctx, e, job) { + const target = job.version ? `${e.name} ${job.version}` : e.name; + const id = encodeURIComponent(e.id); + const busy = !!pending[e.id]; + if (job.state === "running") { + const known = job.progress >= 0 && job.progress <= 1; + return h("div", { class: "inner" }, + h("div", { class: "row" }, h("strong", { class: "grow" }, `${job.action === "update" ? "Updating" : "Installing"} ${target}`), + known && h("span", { class: "small muted" }, `${Math.round(job.progress * 100)} %`)), + known ? h("progress", { max: 1, value: job.progress }) : h("progress", {}), + h("div", { class: "row small muted" }, h("span", { class: "grow" }, phase(job) + (job.started_at && !job.started_at.startsWith("0001") ? ` · started ${ago(job.started_at)}` : "")), + h("button", { class: "link", onclick: () => showLog(e) }, "Show Log"), + h("button", { disabled: busy, onclick: () => work(ctx, e.id, "Cancelling…", () => api(`/v1/catalog/${id}/cancel`, { method: "POST" })) }, "Cancel"))); + } + return h("div", { class: "inner" }, + h("strong", { style: "color: var(--bad)" }, `⚠ ${job.action === "update" ? `Updating ${e.name}` : `Installing ${target}`} failed`), + h("div", { class: "small selectable", style: "color: var(--bad); margin: 4px 0 8px" }, job.error || "Flock reported no details; the log may tell more."), + h("div", { class: "row" }, + h("button", { class: "primary", disabled: busy, onclick: () => work(ctx, e.id, "Starting…", () => api(`/v1/catalog/${id}/install`, { method: "POST", body: { line: job.line || "" } })) }, "Try Again"), + h("button", { disabled: busy, onclick: () => work(ctx, e.id, "", () => api(`/v1/catalog/${id}/job`, { method: "DELETE" })) }, "Dismiss"), + h("button", { class: "link", onclick: () => showLog(e) }, "Show Log"))); +} + +function lineRow(ctx, e, l) { + const id = encodeURIComponent(e.id); + const label = l.label || l.id; + const install = (step) => work(ctx, e.id, step, () => api(`/v1/catalog/${id}/install`, { method: "POST", body: { line: l.id } })); + const facts = [l.installed ? l.version : l.latest ? `Not installed · ${l.latest} available` : "Not installed"]; + if (l.update) facts.push(l.latest === l.version ? "new build available" : `${l.latest} available`); + else if (l.installed) facts.push("up to date"); + if (l.has_data) facts.push(l.installed ? "has data" : "data kept"); + + const actions = !l.installed + ? [h("button", { title: (l.latest ? `Downloads ${e.name} ${l.latest}.` : "Downloads the newest release.") + + (e.installed ? ` ${e.name} ${e.line} keeps running until you switch.` : "") + (l.has_data ? " Its data is still here and comes back." : ""), + onclick: () => install("Starting…") }, "Install")] + : [ + l.update && h("button", { title: `Installs ${l.latest} in place of ${l.version}. Your data stays.`, onclick: () => install("Starting…") }, `Update to ${l.latest}`), + !l.update && h("button", { onclick: () => install("Starting…") }, "Reinstall"), + !l.active && h("button", { onclick: async () => { + const own = l.has_data ? `${e.name} ${label} uses its own data.` : `${e.name} ${label} starts with empty data.`; + if (e.installed && !await ask(`Switch to ${e.name} ${label}?`, `${e.name} ${e.line} stops; its data stays but isn't used until you switch back. ${own}`, "Switch")) return; + work(ctx, e.id, "Switching…", () => api(`/v1/catalog/${id}/activate`, { method: "POST", body: { line: l.id } })); + } }, "Switch…"), + h("button", { onclick: async () => { + const next = e.lines.find((x) => x.installed && x.id !== l.id); + const text = (!l.active ? "" + : next ? `${e.name} ${label} stops and ${e.name} ${next.label || next.id} becomes the active version. ` + : `${e.name} ${label} stops; your sites can't use ${e.name} until you install a version again. `) + + (l.has_data ? `Its data stays unless you delete it below; installing ${label} again picks it up.` : "It holds no data."); + const answer = await ask(`Uninstall ${e.name} ${label}?`, text, "Uninstall", { danger: true, + check: l.has_data && { label: "Also delete its data", warning: "Deleted data can't be recovered." } }); + if (!answer) return; + const q = new URLSearchParams({ line: l.id }); + if (answer.checked) q.set("delete_data", "1"); + work(ctx, e.id, "Uninstalling…", () => api(`/v1/catalog/${id}?${q}`, { method: "DELETE" })); + } }, "Uninstall…"), + ]; + if (catalogWorking(e.id)) for (const a of actions) if (a) a.disabled = true; + return h("div", {}, + h("div", { class: "grow" }, + h("div", { class: "row" }, h(l.installed ? "strong" : "span", {}, `${e.name} ${label}`), l.active && h("span", { class: "tag" }, "Active")), + h("div", { class: "small " + (l.update ? "accent" : "muted") }, facts.join(" · ")), + l.yanked && h("div", { class: "small warn", title: "This build was withdrawn. Flock replaces it as soon as a fixed build is published." }, + `⚠ Withdrawn: ${l.yanked}` + (l.update ? " — update to replace it" : ""))), + ...actions); +} diff --git a/linux/app/frontend/settings/services.js b/linux/app/frontend/settings/services.js new file mode 100644 index 00000000..f511dfda --- /dev/null +++ b/linux/app/frontend/settings/services.js @@ -0,0 +1,193 @@ +// 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, 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 = ` + .svc { display: grid; grid-template-columns: 300px 1fr; gap: 18px; align-items: start; } + .svc-list { position: sticky; top: 0; } + .svc-list h3 { margin: 14px 2px 6px; font-size: 11px; text-transform: uppercase; letter-spacing: 0.05em; color: var(--muted); } + .svc-row { display: flex; align-items: center; gap: 10px; width: 100%; margin-bottom: 4px; padding: 8px 10px; text-align: left; border-radius: 10px; } + .svc-row.selected { border-color: var(--accent); } + .svc-row.off { opacity: 0.6; } + .svc-detail { padding: 16px; display: flex; flex-direction: column; gap: 14px; } + .svc-detail h2 { margin: 0; font-size: 17px; } + .inner { border: 1px solid var(--line); border-radius: 8px; padding: 12px 14px; background: color-mix(in srgb, var(--bg) 50%, transparent); } + .hero { text-align: center; display: flex; flex-direction: column; align-items: center; gap: 8px; } + .versions > div { display: flex; align-items: center; gap: 8px; padding: 9px 0; border-bottom: 1px solid var(--line); } + .versions > div:last-child { border-bottom: none; } + .small { font-size: 12px; } + .warn { color: var(--warn); } + .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.log { width: 720px; } + dialog.log pre { height: 380px; overflow: auto; margin: 0; white-space: pre; } +`; + +const onRequest = (s) => !["web", "dns", "hosts", "mail", "dumps", "postgres"].includes(s.id); +const installable = (s) => s.id === "postgres" || onRequest(s); +const configurablePort = (s) => s.installed && s.port > 0 && s.id !== "web" && s.id !== "dns"; + +// The list and the details + +function updateHint(s) { + if (s.id === "postgres") { + const m = pg?.majors?.find((x) => x.update); + return m && (m.latest === m.installed ? `A new build of PostgreSQL ${m.latest} is available` : `PostgreSQL ${m.latest} is available`); + } + const e = entry(s.id); + const l = e?.lines?.find((x) => x.update); + return l && `${e.name} ${l.latest} is available`; +} + +function workText(s) { + if (pending[s.id]) return pending[s.id]; + if (s.id === "postgres") { + const m = pg?.majors?.find((x) => x.busy); + return m ? BUSY[m.busy] || "Working…" : null; + } + const job = entry(s.id)?.job; + return job?.state === "running" ? phase(job) : null; +} + +function listRow(ctx, s, selected) { + const working = workText(s); + const update = updateHint(s); + const facts = [s.version, s.port > 0 && `Port ${s.port}`, s.installed && !s.running && "Stopped"].filter(Boolean).join(" · "); + const side = working ? working + : s.installed ? "" + : !s.available ? "Not available" + : installable(s) ? "Install…" + : "Not installed"; + return h("button", { class: "svc-row card" + (selected ? " selected" : "") + (s.available ? "" : " off"), title: s.available ? "" : s.unavailable || "", + onclick: () => { selection = s.id; ctx.rerender(); } }, + h("span", { class: "dot" + (s.running ? " ok" : "") }), + h("div", { class: "grow" }, h("div", { class: "ellipsis", style: "font-weight: 600" }, s.name), h("div", { class: "small muted ellipsis" }, facts)), + s.problem && h("span", { class: "warn", title: s.problem }, "⚠"), + update && h("span", { class: "tag accent", title: update }, "Update"), + side && h("span", { class: "small muted" }, side)); +} + +function portField(ctx, s) { + if (!configurablePort(s)) { + return h("div", { class: "row" }, h("strong", { class: "grow" }, "Port"), h("span", { class: "small muted" }, `${s.port} · set by \`flock-php install\``)); + } + const input = h("input", { type: "text", inputmode: "numeric", value: s.port, style: "width: 90px" }); + const save = () => { + const port = parseInt(input.value, 10); + if (port !== s.port) ctx.act(() => api(`/v1/services/${encodeURIComponent(s.id)}`, { method: "PATCH", body: { port } })).then(() => load(ctx)).catch(() => {}); + }; + input.addEventListener("keydown", (e) => e.key === "Enter" && save()); + return h("div", {}, + h("div", { class: "row" }, h("strong", { class: "grow" }, "Port"), input, h("button", { onclick: save }, "Save")), + h("div", { class: "small muted", style: "margin-top: 4px" }, "Ports 1024–65535. Update the port in your projects' .env files too.")); +} + +function detail(ctx, s) { + const tld = ctx.config.tld; + const parts = [h("div", { class: "row" }, h("span", { class: "dot" + (s.running ? " ok" : "") }), h("h2", { class: "grow" }, s.name), + s.installed && s.stoppable && h("button", { + // Not while PostgreSQL switches or a service installs: the daemon (re)starts them itself. + disabled: s.id === "postgres" ? pgWorking() : catalogWorking(s.id), + onclick: async () => { + if (s.running && !await ask(`Stop ${s.name}?`, "Apps that use it can't connect until you start it again.", "Stop", { danger: true })) return; + await ctx.act(() => api(`/v1/services/${encodeURIComponent(s.id)}/${s.running ? "stop" : "start"}`, { method: "POST" })); + await load(ctx).catch(() => {}); + } }, s.running ? "Stop" : "Start"))]; + + // A service Flock can't run here has nothing to configure or connect to: just say why. + if (!s.available) { + parts.push(h("div", { class: "inner hero" }, h("strong", {}, "Not available on Linux"), + h("div", { class: "muted" }, s.unavailable || `Flock can't run ${s.name} on Linux.`))); + return h("div", { class: "card svc-detail" }, ...parts); + } + if (s.problem && !(s.id === "dns" && !s.running)) parts.push(h("div", { class: "small warn selectable" }, `⚠ ${s.problem}`)); + if (s.id === "web") { + parts.push(h("div", { class: "small muted" }, s.running + ? `Caddy serves every site over HTTPS (https://app.${tld}) with certificates from Flock's local certificate authority, which \`flock-php install\` trusts.` + : "Sites don't open while the web server is down. Its log may tell why; `flock-php restart` starts it again.")); + } else if (s.id === "dns") { + parts.push(h("div", { class: "small muted" }, s.running + ? `Every name under .${tld} resolves here, so any subdomain of a site works (tenant1.app.${tld}, a.b.app.${tld}) without adding it.` + : `Wildcard subdomains don't resolve: ${s.problem || "the DNS server isn't running"}. Add the subdomains you use to their site (Sites → the site → Extra domains).`)); + } else if (s.description && !installable(s)) { + parts.push(h("div", { class: "small muted" }, s.description)); + } + if (s.installed && s.port > 0) parts.push(portField(ctx, s)); + if (s.id === "postgres") parts.push(...postgresDetail(ctx)); + if (onRequest(s)) parts.push(...catalogDetail(ctx, s)); + + // How to connect: only once there is something to connect to. + if (s.installed || !installable(s)) { + if (s.env) parts.push(h("div", {}, h("strong", {}, "Environment variables"), h("pre", { class: "code mono selectable", style: "margin-top: 6px" }, s.env))); + if (s.url) parts.push(h("div", {}, h("strong", {}, "Connection"), h("pre", { class: "code mono selectable", style: "margin-top: 6px" }, s.url))); + if (s.log) parts.push(h("div", { class: "row" }, h("strong", { class: "grow" }, "Logs"), h("button", { onclick: () => openWindow("logs") }, "Open"))); + } + return h("div", { class: "card svc-detail" }, ...parts); +} + +export default { + title: "Services", + subtitle: "Databases, caches, search and the tools around your sites. Everything listens on 127.0.0.1 only.", + icon: "server", + color: "#f97316", + render(ctx) { + // Polls while the pane is shown: installs move on in the daemon without a status change. + if (!timer) { + load(ctx).catch(() => {}); + timer = setInterval(() => { + if (!root?.isConnected) { + clearInterval(timer); + timer = null; + } else if (!document.hidden) { + load(ctx).catch(() => {}); + } + }, 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) => + !onRequest(s) || s.installed || workText(s) || entry(s.id)?.job?.state === "failed" || s.id === selection); + if (!shown.length) return (root = h("div", { class: "empty" }, "No services")); + const selected = shown.find((s) => s.id === selection) || shown[0]; + const present = [...new Set(shown.map((s) => s.category))]; + const categories = [...ORDER.filter((c) => present.includes(c)), ...present.filter((c) => !ORDER.includes(c)).sort()]; + const tab = (all, label) => h("button", { class: showAll === all ? "active" : "", onclick: () => { showAll = all; ctx.rerender(); } }, label); + + return (root = h("div", {}, + h("style", {}, css), + h("div", { class: "svc" }, + h("div", { class: "svc-list" }, + h("div", { class: "tabs", style: "padding: 0; margin-bottom: 4px" }, tab(false, "Installed"), tab(true, "All")), + ...categories.flatMap((c) => [h("h3", {}, c), ...shown.filter((s) => s.category === c).map((s) => listRow(ctx, s, s.id === selected.id))]), + shown.length < services.length && h("button", { class: "link", style: "margin-top: 10px", onclick: () => { showAll = true; ctx.rerender(); } }, "More services…")), + detail(ctx, selected)))); + }, +}; diff --git a/linux/app/frontend/settings/sites.js b/linux/app/frontend/settings/sites.js new file mode 100644 index 00000000..2ea8a46a --- /dev/null +++ b/linux/app/frontend/settings/sites.js @@ -0,0 +1,227 @@ +// Settings → Sites: every site in a table, as on flockphp.com: its address, the PHP and Node.js +// 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, 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 = ` + table.sites { width: 100%; border-collapse: separate; border-spacing: 0; table-layout: fixed; } + table.sites td.name > div { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + table.sites th { text-align: left; font-weight: 500; color: var(--muted); padding: 10px 12px; border-bottom: 1px solid var(--line); } + table.sites td { padding: 12px; border-bottom: 1px solid var(--line); vertical-align: top; } + table.sites tr:last-child td { border-bottom: none; } + table.sites td.name strong { font-weight: 650; } + table.sites select { width: 100%; } + table.sites .addr { display: flex; align-items: center; gap: 4px; } + table.sites .addr .icon { color: var(--ok); } + 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); 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; } + .footnote code { font-family: var(--mono); color: var(--fg); } +`; + +function picker(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"; + + const follow = pinned && file ? `Project (${name} ${pinned})` : `Default${fallback ? ` (${name} ${fallback})` : ""}`; + const versions = [...installed]; + if (source === "isolated" && current && !versions.includes(current)) versions.push(current); + const select = h("select", { title: pinned && file ? `Pinned in ${file}` : "" }, + 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 } }))); + return select; +} + +function pinActions(ctx, s, tool) { + const name = tool === "php" ? "PHP" : "Node.js"; + const current = s[tool]; + const file = s[`${tool}_file`]; + return h("div", { class: "row" }, + current && s[`${tool}_source`] !== "project" && + h("button", { onclick: () => ctx.act(() => api(`${site(s.name)}/pin`, { method: "POST", body: { tool, version: current } })) }, `Pin ${name} ${current}`), + s[`${tool}_pinned`] && file && + h("button", { onclick: () => ctx.act(() => api(`${site(s.name)}/pin/${tool}`, { method: "DELETE" })) }, `Unpin from ${file}`), + !current && !s[`${tool}_pinned`] && h("span", { class: "muted" }, `No ${name} version yet`)); +} + +function details(ctx, s) { + const tld = ctx.config.tld; + const input = h("input", { type: "text", placeholder: `tenant1 or app-2.${tld}` }); + const add = () => input.value.trim() && + ctx.act(() => api(`${site(s.name)}/domains`, { method: "POST", body: { domain: input.value.trim() } })); + input.addEventListener("keydown", (e) => e.key === "Enter" && add()); + + 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.addEventListener("change", () => ctx.act(() => api(site(s.name), { method: "PATCH", body: { worker: worker.value } }))); + const fp = s.frankenphp; + + return h("tr", { class: "details" }, h("td", { colspan: 5 }, h("div", { class: "detail-grid" }, + h("span", { class: "k" }, "Pin in the project"), h("div", { class: "row" }, pinActions(ctx, s, "php"), pinActions(ctx, s, "node")), + h("span", { class: "k" }, "Extra domains"), h("div", {}, + (s.domains || []).length > 0 && h("div", { class: "chips", style: "margin-bottom:6px" }, ...s.domains.map((d) => + h("span", { class: "chip", title: d.problem || "" }, (d.problem ? "⚠ " : "") + d.host + (d.alias ? " (alias)" : ""), + h("button", { class: "ghost", title: "Remove", onclick: () => ctx.act(() => api(`${site(s.name)}/domains/${encodeURIComponent(d.entry)}`, { method: "DELETE" })) }, "×")))), + h("div", { class: "row" }, input, h("button", { onclick: add }, "Add"))), + 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: 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 } }))); + const url = s.url || `http://${s.domain}`; + const home = (p) => p.replace(/^\/home\/[^/]+/, "~"); + + return h("tr", {}, + h("td", { class: "name" }, + h("div", { class: "row" }, h("strong", {}, s.name), s.parked && h("span", { class: "tag" }, "Project folder")), + h("div", { class: "addr" }, s.secured && icon("lock", 13), h("button", { class: "link", onclick: () => openURL(url) }, url.replace(/\/$/, ""))), + h("div", { class: "mono muted ellipsis", title: s.path }, home(s.path)), + ...(s.domains || []).map((d) => h("div", {}, h("button", { class: "link", onclick: () => d.url && openURL(d.url) }, d.host + (d.alias ? " (alias)" : "")))), + s.problem && h("div", { style: "color: var(--warn); margin-top: 2px" }, s.problem)), + h("td", {}, picker(ctx, s, "php", (cfg.php || []).map((p) => p.version), cfg.default_php)), + h("td", {}, picker(ctx, s, "node", cfg.node || [], cfg.default_node)), + h("td", {}, secured), + 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, 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 { + title: "Sites", + subtitle: "Sites you added one by one, plus every folder inside your project folders.", + icon: "world", + color: "#3b82f6", + actions: (ctx) => h("button", { onclick: async () => { + const res = await fetch("/app/pick-folder?title=Add%20a%20site", { method: "POST" }); + const path = (await res.text()).trim(); + 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) { + return h("div", { class: "empty" }, "No sites yet. Add one here, or add a project folder in General."); + } + return h("div", {}, + h("style", {}, css), + h("div", { class: "card" }, h("table", { class: "sites" }, + h("colgroup", {}, h("col", {}), h("col", { style: "width:22%" }), h("col", { style: "width:22%" }), h("col", { style: "width:72px" }), h("col", { style: "width:112px" })), + h("thead", {}, h("tr", {}, h("th", {}, "Site"), h("th", {}, "PHP"), h("th", {}, "Node"), h("th", {}, "HTTPS"), h("th", {}))), + h("tbody", {}, ...sites.flatMap((s) => [row(ctx, s, cfg), expanded === s.name && details(ctx, s)])))), + h("p", { class: "footnote" }, "A version picked here applies on this machine only. To set it for everyone working on a project, pin it: PHP goes into ", + h("code", {}, ".flockrc"), ", Node.js into ", h("code", {}, ".nvmrc"), ".")); + }, +}; diff --git a/linux/app/frontend/setup.html b/linux/app/frontend/setup.html new file mode 100644 index 00000000..a20affdd --- /dev/null +++ b/linux/app/frontend/setup.html @@ -0,0 +1,135 @@ + + + + + +Set up Flock + + + + +
+ + + + diff --git a/linux/app/icons/AppIcon.png b/linux/app/icons/AppIcon.png new file mode 100644 index 00000000..0e33c719 Binary files /dev/null and b/linux/app/icons/AppIcon.png differ diff --git a/linux/app/icons/Tray.Dark.png b/linux/app/icons/Tray.Dark.png new file mode 100644 index 00000000..baa237df Binary files /dev/null and b/linux/app/icons/Tray.Dark.png differ diff --git a/linux/app/icons/TrayStopped.Dark.png b/linux/app/icons/TrayStopped.Dark.png new file mode 100644 index 00000000..d8a978ae Binary files /dev/null and b/linux/app/icons/TrayStopped.Dark.png differ diff --git a/linux/app/icons/TrayWarning.Dark.png b/linux/app/icons/TrayWarning.Dark.png new file mode 100644 index 00000000..daaf20c5 Binary files /dev/null and b/linux/app/icons/TrayWarning.Dark.png differ 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 new file mode 100644 index 00000000..055a2199 --- /dev/null +++ b/linux/app/main.go @@ -0,0 +1,423 @@ +// 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 + +import ( + "context" + "embed" + "encoding/json" + "errors" + "io" + "io/fs" + "log" + "net" + "net/http" + "net/http/httputil" + "net/url" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "sync" + "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}, + "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() { + 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.open(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.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() }) + menu.AddSeparator() + menu.Add("Quit").OnClick(func(*application.Context) { f.app.Quit() }) + tray.SetMenu(menu) + + f.app.Event.OnApplicationEvent(events.Common.ApplicationStarted, func(*application.ApplicationEvent) { + // Not before: watch's InvokeSync needs the running app. + go f.watch(tray) + // Started at login (the autostart entry passes --background), it waits in the tray. + if !hasArg("--background") { + f.open(os.Args[1:]) + } + }) + if err := f.app.Run(); err != nil { + log.Fatal(err) + } +} + +// 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`. 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 := "" + if i+1 < len(args) && !strings.HasPrefix(args[i+1], "-") { + section = args[i+1] + } + f.showSection(a, section) + 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") +} + +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) { f.showSection(name, "") } + +// showSection shows a window at a part of its page (its URL's fragment, e.g. a Settings pane). +// Its callers (the tray, the menu, a second instance, app events) run off the main thread, so +// f.windows is only touched there. +func (f *flockApp) showSection(name, section string) { + application.InvokeSync(func() { f.present(name, section) }) +} + +func (f *flockApp) present(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() +} + +func (f *flockApp) toggle(name string) { + application.InvokeSync(func() { + if w := f.window(name); w.IsVisible() { + w.Hide() + } else { + f.present(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-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() + 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") + // 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) + }) + // A folder, or php.ini: only an existing absolute path, so the page can't hand xdg-open a URL, + // a flag or anything else that would get around /app/open's allowlist. + mux.HandleFunc("POST /app/folder", func(w http.ResponseWriter, r *http.Request) { + p := filepath.Clean(r.URL.Query().Get("path")) + info, err := os.Stat(p) + if !filepath.IsAbs(p) || err != nil || !info.IsDir() && filepath.Ext(p) != ".ini" { + http.Error(w, "only folders open", http.StatusBadRequest) + return + } + _ = exec.Command("xdg-open", p).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) + } + }) + // 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 { + http.NotFound(w, r) + return + } + 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() }) + 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 +} + +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) +} 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 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= 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/packaging/aur/flock-php-bin/PKGBUILD b/packaging/aur/flock-php-bin/PKGBUILD new file mode 100644 index 00000000..f1c97326 --- /dev/null +++ b/packaging/aur/flock-php-bin/PKGBUILD @@ -0,0 +1,28 @@ +# Flock's Linux CLI from the release archives, as /usr/bin/flock-php (util-linux owns flock). +# `flock-php install` sets Flock up for the user and copies it to ~/.config/Flock/bin. +# +# 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-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 new file mode 100644 index 00000000..7b2fd60e --- /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-php uninstall' +} 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 }