diff --git a/.cursor/rules/calf.mdc b/.cursor/rules/calf.mdc index ce70d78..89b4ecd 100644 --- a/.cursor/rules/calf.mdc +++ b/.cursor/rules/calf.mdc @@ -11,6 +11,8 @@ Calf is a lightweight, open-source alternative to Docker Desktop: - `backend/` — Go daemon managing containers via `containerd`/`nerdctl`, inside a Lima VM (macOS/Windows) or natively (Linux). Exposes a Docker-API-compatible socket (`~/.config/calf/docker.sock`) plus a REST + WebSocket API on `127.0.0.1:8765`. - `ui/` — native Flutter GUI that drives the daemon over that API. +The Go daemon binary is embedded inside the Flutter `.app` bundle (`Contents/MacOS/calf-daemon`). When the app launches, it spawns the daemon as a subprocess and kills it on close. No separate installation or terminal setup required. + The real `docker` / `docker compose` CLI can point at Calf via `DOCKER_HOST`. Non-goals: no built-in Kubernetes, no extensions marketplace, no Scout/AI/Cloud features (see `ROADMAP.md`). @@ -51,7 +53,7 @@ Non-goals: no built-in Kubernetes, no extensions marketplace, no Scout/AI/Cloud calf/ ├── backend/ Go daemon │ ├── cmd/calf/ -│ │ └── main.go Entrypoint: config/logger/runtime/server wiring, signal handling, PID file, stale-port takeover +│ │ └── main.go Entrypoint: config/logger/runtime/server wiring, signal handling, PID file, stale-port takeover (cross-platform: lsof/Unix, netstat/Windows) │ ├── internal/ │ │ ├── api/ │ │ │ ├── server.go Server struct, route registration for all /v1/... endpoints @@ -110,7 +112,7 @@ calf/ │ └── go.mod / go.sum Module github.com/enegalan/calf/backend, Go 1.22.1 ├── ui/ Flutter application │ ├── lib/ -│ │ ├── main.dart App entrypoint; Material theme bridged from ShadThemeData (light/dark) +│ │ ├── main.dart App entrypoint. Calls `_startDaemon()` to spawn the Go daemon binary (found next to the Flutter executable — `.app` bundle on macOS, alongside the binary on Linux/Windows) and waits for `/v1/status` to respond before showing the UI. Kills the daemon on app close. Inserts common Homebrew paths into `PATH` on macOS (no-op on other platforms). Builds a Material `ThemeData` bridged from a `ShadThemeData` (shadcn_ui), with a hardcoded brand primary color (`#2496ED`). Light/dark `ShadThemeData` instances are built once as top-level finals. │ │ ├── app_shell.dart Sidebar nav, top bar, SettingsScreen (resources, migration, theme) │ │ ├── api/ │ │ │ └── client.dart CalfClient/StatusClient interfaces + ApiClient (http + WebSocket) @@ -144,7 +146,7 @@ calf/ │ └── .dockerignore ├── scripts/verify-docker-cli.sh Verifies `docker` CLI works against Calf's socket ├── .github/workflows/ci.yml CI: backend (vet/test/build) + ui (analyze/test/build) jobs, macos-latest -├── Makefile dev-backend / dev-ui / ui / clean / verify-docker-cli targets +├── Makefile dev-backend / dev-ui-macos / dev-ui-linux / dev-ui-windows / ui-macos / ui-linux / ui-windows / clean / verify-docker-cli / release / release-macos / release-linux / release-windows targets ├── README.md Project pitch + quick start ├── DEVELOPMENT.md Dev setup, config file example, Docker Desktop migration walkthrough ├── ROADMAP.md Phased plan, architecture diagram, non-goals, competitor comparison @@ -252,10 +254,18 @@ Simple JSON files under `~/.config/calf/ui/.json` (via `path_provider`'s a ``` make dev-backend # cd backend && CGO_ENABLED=0 go run ./cmd/calf -make dev-ui # flutter run -d macos -make ui # flutter build macos (alias: make build) -make clean # flutter clean -make verify-docker-cli # runs scripts/verify-docker-cli.sh +make dev-ui-macos # flutter run -d macos +make dev-ui-linux # flutter run -d linux +make dev-ui-windows # flutter run -d windows +make ui-macos # flutter build macos +make ui-linux # flutter build linux +make ui-windows # flutter build windows +make release # build Go daemon + Flutter app for all platforms +make release-macos # build Go daemon + macOS .app +make release-linux # build Go daemon + Linux bundle +make release-windows # build Go daemon + Windows bundle +make clean # flutter clean +make verify-docker-cli # runs scripts/verify-docker-cli.sh ``` CI (`.github/workflows/ci.yml`, both jobs on `macos-latest`): @@ -284,4 +294,4 @@ CI (`.github/workflows/ci.yml`, both jobs on `macos-latest`): ## Before making changes - Check `ROADMAP.md` before adding scope — respect the explicit non-goals. -- Run `make dev-backend` / `make dev-ui` to exercise changes locally. +- Run `make dev-backend` / `make dev-ui-macos` (or `make dev-ui-linux` / `make dev-ui-windows`) to exercise changes locally. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index be460f3..b188404 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,6 +28,12 @@ jobs: working-directory: backend - run: CGO_ENABLED=0 go build -o /dev/null ./cmd/calf working-directory: backend + - name: Cross-compile for Linux + run: CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /dev/null ./cmd/calf + working-directory: backend + - name: Cross-compile for Windows + run: CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -o /dev/null ./cmd/calf + working-directory: backend ui: runs-on: macos-latest diff --git a/.gitignore b/.gitignore index 94f1119..cece170 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,6 @@ .DS_Store .vscode + +# Binaries +backend/calf +backend/calf-daemon diff --git a/CHANGELOG.md b/CHANGELOG.md index e810d79..091a0f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,33 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.7.0] - 2026-07-08 + +### Added + +- **Cross-platform support** — Calf now runs on Linux, macOS, and Windows. +- **Automated cross-platform builds** verified in CI for Linux and Windows. +- **Windows port conflict cleanup** — stale daemon instances are detected and removed on Windows. +- **Linux build target** — `make ui-linux` and `make release-linux` are available. +- **Windows build target** — `make ui-windows` and `make release-windows` are available. +- **`docker compose` no longer hangs** — compose commands finish reliably. + +### Changed + +- Port conflict cleanup works across operating systems. +- PATH setup on macOS prefers Homebrew paths while leaving other systems unchanged. +- Links and URLs open with the platform handler on all supported systems. +- The bundled daemon binary is discovered next to the app executable on all platforms. +- Startup socket checks log missing sockets at Debug level instead of Warn. +- macOS release entitlements tightened: removed unsafe memory and library-validation exceptions. The app sandbox remains off so the backend can run required external tools. + +### Fixed + +- Container list parsing handles both structured and comma-separated labels. +- Streaming log and interactive exec commands consistently route through the VM runtime. +- macOS release build produces a single daemon binary that works on both Intel and Apple Silicon Macs, and signs it correctly so the app can launch the backend. +- UI startup spinner clears transient daemon errors once the runtime reaches the running state and no longer pretends the app is ready after the timeout expires. + ## [0.6.0] - 2026-07-06 ### Changed diff --git a/CLAUDE.md b/CLAUDE.md index 11afa84..157c826 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,6 +9,8 @@ This file provides guidance to AI assistants when working with code in this repo - A **Go daemon** (`backend/`) that manages containers through `containerd` + `nerdctl`, running inside a **Lima** VM on macOS/Windows, or talking directly to the host runtime on Linux. - A **native Flutter GUI** (`ui/`) that drives the daemon over a local REST + WebSocket API. +The Go daemon binary is embedded inside the Flutter `.app` bundle (`Contents/MacOS/calf-daemon`). When the app launches, it spawns the daemon as a subprocess and kills it on close. No separate installation or terminal setup required. + The daemon also exposes a Docker-API-compatible socket (`~/.config/calf/docker.sock`), so the real `docker` / `docker compose` CLI can point at Calf via `DOCKER_HOST`. Non-goals (see `ROADMAP.md`): no built-in Kubernetes, no extensions marketplace, no Scout/AI/Cloud features. @@ -49,7 +51,7 @@ Non-goals (see `ROADMAP.md`): no built-in Kubernetes, no extensions marketplace, calf/ ├── backend/ Go daemon │ ├── cmd/calf/ -│ │ └── main.go Entrypoint: config/logger/runtime/server wiring, signal handling, PID file, stale-port takeover +│ │ └── main.go Entrypoint: config/logger/runtime/server wiring, signal handling, PID file, stale-port takeover (cross-platform: lsof/Unix, netstat/Windows) │ ├── internal/ │ │ ├── api/ │ │ │ ├── server.go Server struct, route registration for all /v1/... endpoints @@ -142,7 +144,7 @@ calf/ │ └── .dockerignore ├── scripts/verify-docker-cli.sh Verifies `docker` CLI works against Calf's socket ├── .github/workflows/ci.yml CI: backend (vet/test/build) + ui (analyze/test/build) jobs, macos-latest -├── Makefile dev-backend / dev-ui / ui / clean / verify-docker-cli targets +├── Makefile dev-backend / dev-ui-macos / dev-ui-linux / dev-ui-windows / ui-macos / ui-linux / ui-windows / clean / verify-docker-cli / release / release-macos / release-linux / release-windows targets ├── README.md Project pitch + quick start ├── DEVELOPMENT.md Dev setup, config file example, Docker Desktop migration walkthrough ├── ROADMAP.md Phased plan, architecture diagram, non-goals, competitor comparison @@ -207,7 +209,7 @@ Docker Hub OAuth2 device-code flow client. Polls for a token, decodes JWT claims ## UI File Reference (`ui/lib/`) ### `main.dart` -Builds a Material `ThemeData` bridged from a `ShadThemeData` (shadcn_ui), with a hardcoded brand primary color (`#2496ED`). Light/dark `ShadThemeData` instances are built once as top-level finals. +App entrypoint. Calls `_startDaemon()` to spawn the Go daemon binary (found next to the Flutter executable — `.app` bundle on macOS, alongside the binary on Linux/Windows) and waits for `/v1/status` to respond before showing the UI. Kills the daemon on app close. Inserts common Homebrew paths into `PATH` on macOS (no-op on other platforms). Builds a Material `ThemeData` bridged from a `ShadThemeData` (shadcn_ui), with a hardcoded brand primary color (`#2496ED`). Light/dark `ShadThemeData` instances are built once as top-level finals. ### `app_shell.dart` Sidebar navigation (Containers / Images / Volumes / Builds) plus the settings screen, and a top bar showing Docker Hub registry sign-in status. `SettingsScreen` handles CPU/memory/swap slider configuration (bounded by host capacity from `/v1/config`), Docker Desktop migration trigger + polling, and theme mode switching. @@ -250,10 +252,18 @@ Simple JSON files under `~/.config/calf/ui/.json` (via `path_provider`'s a ``` make dev-backend # cd backend && CGO_ENABLED=0 go run ./cmd/calf -make dev-ui # flutter run -d macos -make ui # flutter build macos (alias: make build) -make clean # flutter clean -make verify-docker-cli # runs scripts/verify-docker-cli.sh +make dev-ui-macos # flutter run -d macos +make dev-ui-linux # flutter run -d linux +make dev-ui-windows # flutter run -d windows +make ui-macos # flutter build macos +make ui-linux # flutter build linux +make ui-windows # flutter build windows +make release # build Go daemon + Flutter app for all platforms +make release-macos # build Go daemon + macOS .app +make release-linux # build Go daemon + Linux bundle +make release-windows # build Go daemon + Windows bundle +make clean # flutter clean +make verify-docker-cli # runs scripts/verify-docker-cli.sh ``` CI (`.github/workflows/ci.yml`, both jobs on `macos-latest`): diff --git a/Makefile b/Makefile index 9f4a387..0b5376c 100644 --- a/Makefile +++ b/Makefile @@ -1,22 +1,62 @@ -.PHONY: help ui build clean dev dev-backend dev-ui verify-docker-cli +.PHONY: help ui-macos clean dev dev-backend dev-ui-macos verify-docker-cli release-macos \ + ui-linux ui-windows dev-ui-linux dev-ui-windows release-linux release-windows release help: @echo "Calf — common commands" @echo "" - @echo " make dev-backend API daemon on :8765 (terminal 1)" - @echo " make dev-ui Flutter app on macOS (terminal 2)" + @echo " make dev-backend API daemon on :8765 (terminal 1)" + + @echo " make dev-ui-macos Flutter app on macOS (terminal 2)" + @echo " make dev-ui-linux Flutter app on Linux (terminal 2)" + @echo " make dev-ui-windows Flutter app on Windows (terminal 2)" + + @echo "" + @echo " make ui-macos build macOS app" + @echo " make ui-linux build Linux app" + @echo " make ui-windows build Windows app" + @echo "" - @echo " make ui build macOS app" - @echo " make build build macOS app" - @echo " make clean remove build artifacts" + @echo " make release-macos build Go daemon + macOS app" + @echo " make release-linux build Go daemon + Linux app" + @echo " make release-windows build Go daemon + Windows app" + @echo " make release build for all platforms (macOS + Linux + Windows)" + @echo " make clean remove build artifacts" @echo " make verify-docker-cli smoke-test docker CLI against Calf" @echo "" @echo "Full guide: DEVELOPMENT.md" -ui: +ui-macos: + cd ui && flutter build macos + +ui-linux: + cd ui && flutter build linux + +ui-windows: + cd ui && flutter build windows + +release: release-macos release-linux release-windows + +release-macos: + cd backend && CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -o calf-daemon-amd64 ./cmd/calf + cd backend && CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -o calf-daemon-arm64 ./cmd/calf + lipo -create -output backend/calf-daemon backend/calf-daemon-amd64 backend/calf-daemon-arm64 + rm backend/calf-daemon-amd64 backend/calf-daemon-arm64 cd ui && flutter build macos + cp backend/calf-daemon ui/build/macos/Build/Products/Release/Calf.app/Contents/MacOS/calf-daemon + rm backend/calf-daemon + codesign --force --sign - --identifier com.enegalan.calf.daemon ui/build/macos/Build/Products/Release/Calf.app/Contents/MacOS/calf-daemon + codesign --force --sign - --entitlements ui/macos/Runner/Release.entitlements ui/build/macos/Build/Products/Release/Calf.app + codesign --verify --deep --strict ui/build/macos/Build/Products/Release/Calf.app -build: ui +release-linux: ui-linux + cd backend && CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o calf-daemon ./cmd/calf + cp backend/calf-daemon ui/build/linux/x64/release/bundle/calf-daemon + rm backend/calf-daemon + +release-windows: ui-windows + cd backend && CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -o calf-daemon.exe ./cmd/calf + cp backend/calf-daemon.exe ui/build/windows/x64/runner/Release/calf-daemon.exe + rm backend/calf-daemon.exe clean: cd ui && flutter clean @@ -24,9 +64,15 @@ clean: dev-backend: cd backend && CGO_ENABLED=0 go run ./cmd/calf -dev-ui: +dev-ui-macos: cd ui && flutter run -d macos +dev-ui-linux: + cd ui && flutter run -d linux + +dev-ui-windows: + cd ui && flutter run -d windows + verify-docker-cli: ./scripts/verify-docker-cli.sh diff --git a/ROADMAP.md b/ROADMAP.md index f831867..db9a7a3 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -142,7 +142,7 @@ Be a **valid** Docker Desktop replacement for local development: same CLI (`dock ### 3.1 Installation and lifecycle - [ ] macOS installer (.dmg / .pkg) with signing and notarization -- [ ] Daemon as a user service (launchd / systemd) +- [x] Daemon embedded in .app bundle, spawned by Flutter app on launch, killed on close - [ ] In-app updates - [ ] Windows installer (WSL2 + integration) - [ ] Linux installer (.deb / .rpm / AppImage) diff --git a/backend/api.test b/backend/api.test deleted file mode 100755 index 652af86..0000000 Binary files a/backend/api.test and /dev/null differ diff --git a/backend/buildstore.test b/backend/buildstore.test deleted file mode 100755 index 4a96f46..0000000 Binary files a/backend/buildstore.test and /dev/null differ diff --git a/backend/cmd/calf/main.go b/backend/cmd/calf/main.go index 1d021b6..2d083ff 100644 --- a/backend/cmd/calf/main.go +++ b/backend/cmd/calf/main.go @@ -2,6 +2,7 @@ package main import ( "context" + "errors" "fmt" "log/slog" "net" @@ -9,6 +10,7 @@ import ( "os/exec" "os/signal" "path/filepath" + goRuntime "runtime" "strconv" "strings" "syscall" @@ -24,6 +26,8 @@ func main() { } func run() int { + os.Setenv("PATH", ensurePath()) + cfg, err := config.Load() if err != nil { slog.Error("failed to load config", "error", err) @@ -57,6 +61,8 @@ func run() int { ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer stop() + go watchParent(ctx, os.Getppid(), stop, logger) + rtCtx, rtCancel := context.WithCancel(ctx) defer rtCancel() @@ -82,8 +88,8 @@ func run() int { if err != nil { logger.Error("server stopped", "error", err) rtCancel() - stopCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() + stopCtx, stopCancel := context.WithTimeout(context.Background(), 30*time.Second) + defer stopCancel() if stopErr := rt.Stop(stopCtx); stopErr != nil { logger.Error("runtime stop failed", "error", stopErr) } @@ -91,16 +97,16 @@ func run() int { } case <-ctx.Done(): logger.Info("shutting down") - shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() + shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second) + defer shutdownCancel() if err := server.Shutdown(shutdownCtx); err != nil { logger.Error("shutdown failed", "error", err) } } rtCancel() - stopCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() + stopCtx, stopCancel := context.WithTimeout(context.Background(), 30*time.Second) + defer stopCancel() if err := rt.Stop(stopCtx); err != nil { logger.Error("runtime stop failed", "error", err) } @@ -130,8 +136,8 @@ func ensurePort(addr string) error { return nil } - pid := findPidOnPort(port) - if pid == 0 { + pid, err := findPidOnPort(port) + if err != nil || pid == 0 { return fmt.Errorf("port %s is in use; run: pkill -f calf", addr) } @@ -140,8 +146,14 @@ func ensurePort(addr string) error { return fmt.Errorf("port %s is in use by pid %d", addr, pid) } - proc, _ := os.FindProcess(pid) - if proc != nil { + proc, err := os.FindProcess(pid) + if err != nil { + return fmt.Errorf("port %s is in use by pid %d, but process not found", addr, pid) + } + + if goRuntime.GOOS == "windows" { + proc.Kill() + } else { proc.Signal(syscall.SIGTERM) } @@ -157,10 +169,19 @@ func ensurePort(addr string) error { return fmt.Errorf("port %s is still in use after cleanup; run: pkill -f calf", addr) } -func findPidOnPort(port string) int { +func findPidOnPort(port string) (int, error) { + switch goRuntime.GOOS { + case "windows": + return findPidOnPortWindows(port) + default: + return findPidOnPortUnix(port) + } +} + +func findPidOnPortUnix(port string) (int, error) { out, err := exec.Command("lsof", "-ti", fmt.Sprintf(":%s", port), "-s", "TCP:LISTEN").Output() if err != nil { - return 0 + return 0, err } for _, raw := range strings.Fields(string(out)) { pid, err := strconv.Atoi(strings.TrimSpace(raw)) @@ -168,10 +189,32 @@ func findPidOnPort(port string) int { continue } if pid != os.Getpid() && pid != os.Getppid() { - return pid + return pid, nil } } - return 0 + return 0, errors.New("no pid found on port") +} + +func findPidOnPortWindows(port string) (int, error) { + out, err := exec.Command("netstat", "-ano").Output() + if err != nil { + return 0, err + } + target := fmt.Sprintf(":%s", port) + for _, line := range strings.Split(string(out), "\n") { + line = strings.TrimSpace(line) + fields := strings.Fields(line) + if len(fields) < 5 || fields[3] != "LISTENING" { + continue + } + if strings.HasSuffix(fields[1], target) { + pid, err := strconv.Atoi(strings.TrimSpace(fields[len(fields)-1])) + if err == nil && pid != os.Getpid() && pid != os.Getppid() { + return pid, nil + } + } + } + return 0, errors.New("no pid found on port") } func pidFilePath() string { @@ -179,6 +222,38 @@ func pidFilePath() string { return filepath.Join(home, ".config", "calf", "calf.pid") } +func ensurePath() string { + existing := os.Getenv("PATH") + if goRuntime.GOOS == "windows" { + return existing + } + + needed := false + for _, dir := range []string{"/opt/homebrew/bin", "/usr/local/bin"} { + if _, err := os.Stat(filepath.Join(dir, "limactl")); err == nil && !inPath(dir, existing) { + existing = dir + ":" + existing + needed = true + } + } + if !needed { + for _, dir := range []string{"/opt/homebrew/bin", "/opt/homebrew/sbin", "/usr/local/bin"} { + if !inPath(dir, existing) { + existing = dir + ":" + existing + } + } + } + return existing +} + +func inPath(dir, path string) bool { + for _, p := range strings.Split(path, ":") { + if p == dir { + return true + } + } + return false +} + func readPidFile() (int, error) { data, err := os.ReadFile(pidFilePath()) if err != nil { diff --git a/backend/cmd/calf/watchdog_unix.go b/backend/cmd/calf/watchdog_unix.go new file mode 100644 index 0000000..0722fbf --- /dev/null +++ b/backend/cmd/calf/watchdog_unix.go @@ -0,0 +1,27 @@ +//go:build !windows + +package main + +import ( + "context" + "log/slog" + "os" + "time" +) + +func watchParent(ctx context.Context, _ int, stop context.CancelFunc, logger *slog.Logger) { + ticker := time.NewTicker(5 * time.Second) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + if os.Getppid() == 1 { + logger.Warn("parent process died, shutting down") + stop() + return + } + } + } +} diff --git a/backend/cmd/calf/watchdog_windows.go b/backend/cmd/calf/watchdog_windows.go new file mode 100644 index 0000000..0bd5a85 --- /dev/null +++ b/backend/cmd/calf/watchdog_windows.go @@ -0,0 +1,48 @@ +//go:build windows + +package main + +import ( + "context" + "log/slog" + "sync" + + "golang.org/x/sys/windows" +) + +func watchParent(ctx context.Context, parentPID int, stop context.CancelFunc, logger *slog.Logger) { + handle, err := windows.OpenProcess(windows.SYNCHRONIZE, false, uint32(parentPID)) + if err != nil { + logger.Warn("could not open parent process handle, skipping parent watchdog", "error", err) + return + } + defer windows.CloseHandle(handle) + + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + const pollMs = 1000 + for { + select { + case <-ctx.Done(): + return + default: + } + + result, err := windows.WaitForSingleObject(handle, pollMs) + if err != nil { + logger.Warn("parent process wait failed", "error", err) + return + } + if result == windows.WAIT_OBJECT_0 { + logger.Warn("parent process died, shutting down") + stop() + return + } + } + }() + + <-ctx.Done() + wg.Wait() +} diff --git a/backend/go.mod b/backend/go.mod index 0309d5d..c710184 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -3,7 +3,8 @@ module github.com/enegalan/calf/backend go 1.22.1 require ( - github.com/creack/pty v1.1.24 // indirect - github.com/gorilla/websocket v1.5.3 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect + github.com/creack/pty v1.1.24 + github.com/gorilla/websocket v1.5.3 + golang.org/x/sys v0.24.0 + gopkg.in/yaml.v3 v3.0.1 ) diff --git a/backend/go.sum b/backend/go.sum index fe6138c..9a7c0bf 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -2,6 +2,9 @@ github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +golang.org/x/sys v0.24.0 h1:Twjiwq9dn6R1fQcyiK+wQyHWfaz/BJB+YIpzU/Cv3Xg= +golang.org/x/sys v0.24.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 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/backend/internal/runtime/lima.go b/backend/internal/runtime/lima.go index 40636c5..40bb7f6 100644 --- a/backend/internal/runtime/lima.go +++ b/backend/internal/runtime/lima.go @@ -4,16 +4,21 @@ import ( "context" _ "embed" "encoding/json" + "errors" "fmt" "io" "log/slog" "net" + "net/http" + "net/http/httputil" + "net/url" "os" "os/exec" "path/filepath" "strings" "sync" "sync/atomic" + "syscall" "time" ) @@ -38,6 +43,8 @@ type Lima struct { lastState State localhostProxy *localhostProxies watcherCancel context.CancelFunc + proxyListener net.Listener + dockerProxy *http.Server } func NewLima(vmName string, dockerSocket string, cpus int, memoryGB int, memorySwapGB int, diskGB int, apiListenPort int, proxy ProxyConfig) *Lima { @@ -102,6 +109,10 @@ func (l *Lima) Start(ctx context.Context) error { } } + if err := l.ensureDockerCLISocket(); err != nil { + limaLogger.Warn("failed to set up Docker CLI socket symlink (non-fatal)", "error", err) + } + l.started.Store(true) wCtx, wCancel := context.WithCancel(context.Background()) l.watcherCancel = wCancel @@ -129,6 +140,7 @@ func (l *Lima) Stop(ctx context.Context) error { } l.localhostProxy.stopAll() + l.removeDockerCLISocket() l.started.Store(false) _, err = runCommand(ctx, "limactl", "stop", l.vmName) @@ -144,29 +156,55 @@ func (l *Lima) Status(ctx context.Context) (Status, error) { } if _, err := exec.LookPath("limactl"); err != nil { + limaLogger.Warn("limactl not found", "error", err, "PATH", os.Getenv("PATH")) + status.Log = "limactl not found: install Lima first" return status, nil } output, err := runCommand(ctx, "limactl", "list", "--format", "{{.Name}}\t{{.Status}}") if err != nil { + limaLogger.Warn("limactl list failed", "error", err) + status.Log = fmt.Sprintf("limactl list failed: %v", err) return status, nil } + limaLogger.Debug("limactl list output", "output", string(output)) + found := false for _, line := range strings.Split(string(output), "\n") { fields := strings.Split(line, "\t") if len(fields) != 2 || fields[0] != l.vmName { continue } + found = true if strings.Contains(strings.ToLower(fields[1]), "running") { status.State = StateRunning + } else { + limaLogger.Warn("vm not running", "vm", l.vmName, "status", fields[1]) } } + if !found { + status.Log = "VM not found in limactl list" + } + if status.State == StateRunning && l.dockerSocket != "" { conn, err := net.DialTimeout("unix", l.dockerSocket, 100*time.Millisecond) if err != nil { - status.State = StateStopped + if errors.Is(err, syscall.ENOENT) { + limaLogger.Debug("socket not ready yet (expected during startup)", "socket", l.dockerSocket) + } else { + limaLogger.Warn("socket dial failed", "socket", l.dockerSocket, "error", err) + } + // Fall back to TCP on port 2375 (via Lima port forward + socat inside VM) + tcpConn, tcpErr := net.DialTimeout("tcp", "127.0.0.1:2375", 100*time.Millisecond) + if tcpErr != nil { + limaLogger.Warn("tcp fallback also failed", "error", tcpErr) + status.State = StateStopped + status.Log = fmt.Sprintf("socket dial failed: %v", err) + } else { + tcpConn.Close() + } } else { conn.Close() } @@ -447,7 +485,7 @@ func (l *Lima) StreamLogsFollow(ctx context.Context, id string, output func(stri } func (l *Lima) streamLogsFollow(ctx context.Context, id, since string, output func(string)) error { - command := exec.CommandContext(ctx, "limactl", append([]string{"shell", l.vmName, "--"}, NerdctlVMArgs("logs", "-f", "--since", since, id)...)...) + command := exec.CommandContext(ctx, "limactl", append([]string{"shell", l.vmName, "--"}, vmCommand("nerdctl", "logs", "-f", "--since", since, id)...)...) command.Env = limaShellEnv() return streamCommandLogs(ctx, command, output) } @@ -494,7 +532,7 @@ func (l *Lima) AttachExec(ctx context.Context, id string, stdin io.Reader, onOut return err } - shellArgs := append([]string{"shell", l.vmName, "--"}, NerdctlVMArgs(interactiveExecArgs(id)...)...) + shellArgs := append([]string{"shell", l.vmName, "--"}, vmCommand("nerdctl", interactiveExecArgs(id)...)...) command := exec.CommandContext(ctx, "limactl", shellArgs...) return attachExecInContainer(ctx, command, stdin, onOutput, resizeCh) } @@ -554,9 +592,12 @@ func (l *Lima) RegistryLogout(ctx context.Context, server string) error { return registryLogout(ctx, l.runInVM, server) } +// vmCommand routes commands for the Lima VM. "nerdctl" is transparently +// redirected to "docker" because the VM runs Docker Engine — containers +// live in its moby namespace, not containerd's default namespace. func vmCommand(command string, args ...string) []string { if command == "nerdctl" { - return NerdctlVMArgs(args...) + return append([]string{"sudo", "docker"}, args...) } return append([]string{command}, args...) @@ -658,3 +699,95 @@ func defaultDockerSocket() string { return filepath.Join(home, ".config", "calf", "docker.sock") } + +func dockerCLISocketPath() string { + home, err := os.UserHomeDir() + if err != nil { + return "" + } + + return filepath.Join(home, ".docker", "run", "docker.sock") +} + +func (l *Lima) ensureDockerCLISocket() error { + if l.dockerSocket == "" { + return nil + } + + dir := filepath.Dir(l.dockerSocket) + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("create %s: %w", dir, err) + } + + os.Remove(l.dockerSocket) + + listener, err := net.Listen("unix", l.dockerSocket) + if err != nil { + return fmt.Errorf("listen on %s: %w", l.dockerSocket, err) + } + + l.mu.Lock() + l.proxyListener = listener + l.mu.Unlock() + + dockerCliPath := dockerCLISocketPath() + if dockerCliPath != "" { + os.MkdirAll(filepath.Dir(dockerCliPath), 0o755) + os.Remove(dockerCliPath) + if err := os.Symlink(l.dockerSocket, dockerCliPath); err != nil { + limaLogger.Warn("failed to create Docker CLI symlink", "path", dockerCliPath, "error", err) + } + } + + go l.serveTCPProxy() + + return nil +} + +func (l *Lima) serveTCPProxy() { + targetURL, err := url.Parse("http://127.0.0.1:2375") + if err != nil { + limaLogger.Warn("docker HTTP proxy: invalid target URL", "error", err) + return + } + + proxy := httputil.NewSingleHostReverseProxy(targetURL) + proxy.ErrorLog = slog.NewLogLogger(slog.NewTextHandler(io.Discard, nil), slog.LevelError) + proxy.Transport = &http.Transport{ + DialContext: (&net.Dialer{Timeout: 5 * time.Second}).DialContext, + } + + server := &http.Server{Handler: proxy} + + l.mu.Lock() + l.dockerProxy = server + listener := l.proxyListener + l.mu.Unlock() + + if err := server.Serve(listener); err != nil && err != http.ErrServerClosed { + limaLogger.Warn("docker HTTP proxy: serve error", "error", err) + } +} + +func (l *Lima) removeDockerCLISocket() { + l.mu.Lock() + server := l.dockerProxy + listener := l.proxyListener + l.dockerProxy = nil + l.proxyListener = nil + l.mu.Unlock() + + if server != nil { + server.Close() + } + if listener != nil { + listener.Close() + } + + os.Remove(l.dockerSocket) + + dockerCliPath := dockerCLISocketPath() + if dockerCliPath != "" { + os.Remove(dockerCliPath) + } +} diff --git a/backend/internal/runtime/lima.yaml b/backend/internal/runtime/lima.yaml index 3e63edb..6a9cb60 100644 --- a/backend/internal/runtime/lima.yaml +++ b/backend/internal/runtime/lima.yaml @@ -36,7 +36,7 @@ provision: #!/bin/bash set -eux -o pipefail apt-get update - DEBIAN_FRONTEND=noninteractive apt-get install -y containerd docker.io curl ca-certificates + DEBIAN_FRONTEND=noninteractive apt-get install -y containerd docker.io curl ca-certificates socat systemctl enable --now containerd systemctl enable --now docker if [ -f /mnt/lima-cidata/lima.env ]; then @@ -83,6 +83,22 @@ provision: sudo systemctl daemon-reload sudo systemctl restart containerd docker + sudo tee /etc/systemd/system/docker-tcp-proxy.service >/dev/null <<'SERVICEEOF' + [Unit] + Description=Docker TCP Proxy + After=docker.service + Requires=docker.service + [Service] + Type=simple + ExecStart=/usr/bin/socat TCP-LISTEN:2375,bind=127.0.0.1,reuseaddr,fork UNIX-CONNECT:/var/run/docker.sock + Restart=always + RestartSec=5 + [Install] + WantedBy=multi-user.target +SERVICEEOF + sudo systemctl enable docker-tcp-proxy + sudo systemctl start docker-tcp-proxy + portForwards: - - guestSocket: "/var/run/docker.sock" - hostSocket: "%[2]s/.config/calf/docker.sock" + - guestPort: 2375 + hostPort: 2375 diff --git a/backend/internal/runtime/nerdctl.go b/backend/internal/runtime/nerdctl.go index 8249acc..3cc982d 100644 --- a/backend/internal/runtime/nerdctl.go +++ b/backend/internal/runtime/nerdctl.go @@ -118,8 +118,11 @@ func pushImage(ctx context.Context, run commandRunner, ref string) error { return nil } + + func ParseContainerLines(output []byte) ([]Container, error) { containers := make([]Container, 0) + seen := make(map[string]struct{}) scanner := bufio.NewScanner(bytes.NewReader(output)) for scanner.Scan() { @@ -128,16 +131,39 @@ func ParseContainerLines(output []byte) ([]Container, error) { continue } - var row nerdctlLine + var row struct { + ID string `json:"ID"` + Names string `json:"Names"` + Image string `json:"Image"` + State string `json:"State"` + Status string `json:"Status"` + Ports string `json:"Ports"` + CreatedAt string `json:"CreatedAt"` + Labels json.RawMessage `json:"Labels"` + } if err := json.Unmarshal([]byte(line), &row); err != nil { continue } - project, service := composeFields(row.Names, row.Labels) + if row.ID == "" { + continue + } + if _, exists := seen[row.ID]; exists { + continue + } + seen[row.ID] = struct{}{} + + containerName := row.Names + if containerName == "" { + containerName = row.ID + } + + labels := parseLabels(row.Labels) + project, service := composeFields(containerName, labels) containers = append(containers, Container{ ID: row.ID, - Name: row.Names, + Name: containerName, Image: row.Image, State: containerState(row.Status, row.State), Status: row.Status, @@ -157,6 +183,45 @@ func ParseContainerLines(output []byte) ([]Container, error) { return containers, nil } +// parseLabels decodes nerdctl label output. The comma-separated path is +// intentional: label values that contain commas will be split and fragments +// without '=' are ignored, matching nerdctl's default formatting. +func parseLabels(raw json.RawMessage) map[string]string { + if len(raw) == 0 { + return nil + } + + if raw[0] == '{' { + var labels map[string]string + if err := json.Unmarshal(raw, &labels); err == nil { + return labels + } + return nil + } + + var s string + if err := json.Unmarshal(raw, &s); err != nil { + return nil + } + if s == "" { + return nil + } + + labels := make(map[string]string) + for _, pair := range strings.Split(s, ",") { + pair = strings.TrimSpace(pair) + if pair == "" { + continue + } + parts := strings.SplitN(pair, "=", 2) + if len(parts) == 2 { + labels[parts[0]] = parts[1] + } + } + + return labels +} + func ParseImageLines(output []byte) ([]Image, error) { images := make([]Image, 0) scanner := bufio.NewScanner(bytes.NewReader(output)) diff --git a/backend/internal/runtime/runtime.go b/backend/internal/runtime/runtime.go index b424d37..34b5994 100644 --- a/backend/internal/runtime/runtime.go +++ b/backend/internal/runtime/runtime.go @@ -34,6 +34,7 @@ type Status struct { DockerSocket string `json:"docker_socket"` VMName string `json:"vm_name,omitempty"` PortConflicts []PortConflict `json:"port_conflicts,omitempty"` + Log string `json:"log,omitempty"` } type Container struct { diff --git a/backend/runtime.test b/backend/runtime.test deleted file mode 100755 index 2c73d44..0000000 Binary files a/backend/runtime.test and /dev/null differ diff --git a/backend/version/version.go b/backend/version/version.go index 6cc8872..2c0474f 100644 --- a/backend/version/version.go +++ b/backend/version/version.go @@ -1,3 +1,3 @@ package version -const Version = "0.6.0" +const Version = "0.7.0" diff --git a/ui/lib/app_shell.dart b/ui/lib/app_shell.dart index 77b3f75..77194b8 100644 --- a/ui/lib/app_shell.dart +++ b/ui/lib/app_shell.dart @@ -247,7 +247,6 @@ class SettingsScreen extends StatefulWidget { } class _SettingsScreenState extends State { - bool _startAtLogin = false; Config? _config; bool _configLoading = true; String? _configError; @@ -465,15 +464,6 @@ class _SettingsScreenState extends State { children: [ Text('Settings', style: theme.textTheme.h3), const SizedBox(height: 24), - _sectionHeader('General', theme), - const SizedBox(height: 12), - _settingRow( - 'Start Calf when you sign in to your computer', - ShadSwitch( - value: _startAtLogin, - onChanged: (value) => setState(() => _startAtLogin = value), - ), - ), const SizedBox(height: 12), _settingRow( 'Use Calf for Docker CLI', diff --git a/ui/lib/main.dart b/ui/lib/main.dart index 9428da3..f822391 100644 --- a/ui/lib/main.dart +++ b/ui/lib/main.dart @@ -1,4 +1,9 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; import 'package:shadcn_ui/shadcn_ui.dart'; import 'package:ui/api/client.dart'; @@ -22,6 +27,101 @@ final _darkShadTheme = ShadThemeData( ), ); +Process? _daemonProcess; +bool _daemonShutdown = false; +Timer? _daemonRestartTimer; +int _daemonRestartAttempts = 0; +const _maxDaemonRestarts = 5; + +Future _startDaemon() async { + if (_daemonShutdown) { + return; + } + + final daemonPath = _findDaemon(); + + if (daemonPath == null) { + return; + } + + try { + final env = Map.from(Platform.environment); + final path = env['PATH'] ?? ''; + final extras = _extraPaths(); + if (extras.isNotEmpty) { + env['PATH'] = '$extras:$path'; + } + _daemonProcess = await Process.start(daemonPath, [], runInShell: false, environment: env); + _daemonRestartAttempts = 0; + _daemonProcess!.stdout.listen((data) => stdout.add(data)); + _daemonProcess!.stderr.listen((data) => stderr.add(data)); + _daemonProcess!.exitCode.then((code) { + stderr.writeln('calf-daemon exited with code $code'); + if (_daemonShutdown || _daemonProcess == null) { + _daemonProcess = null; + return; + } + _daemonProcess = null; + _daemonRestartAttempts++; + if (_daemonRestartAttempts > _maxDaemonRestarts) { + stderr.writeln('calf-daemon failed to stay running after $_maxDaemonRestarts attempts'); + return; + } + final delay = Duration(seconds: _daemonRestartAttempts); + _daemonRestartTimer = Timer(delay, _startDaemon); + }); + } catch (e) { + stderr.writeln('failed to start calf-daemon: $e'); + } +} + +String? _findDaemon() { + final dir = File(Platform.resolvedExecutable).parent.path; + final candidates = Platform.isWindows + ? ['$dir/calf-daemon.exe', '$dir/calf-daemon'] + : ['$dir/calf-daemon']; + for (final daemonPath in candidates) { + if (File(daemonPath).existsSync()) { + return daemonPath; + } + } + stderr.writeln('calf-daemon not found in $dir'); + return null; +} + +String _extraPaths() { + if (Platform.isMacOS) { + final path = Platform.environment['PATH'] ?? ''; + final missing = []; + for (final dir in ['/opt/homebrew/bin', '/opt/homebrew/sbin', '/usr/local/bin']) { + if (!path.contains(dir)) { + missing.add(dir); + } + } + return missing.join(':'); + } + return ''; +} + +Future _stopDaemon() async { + _daemonShutdown = true; + _daemonRestartTimer?.cancel(); + _daemonRestartTimer = null; + final process = _daemonProcess; + if (process == null) return; + _daemonProcess = null; + process.kill(); + await process.exitCode.timeout(const Duration(seconds: 5), onTimeout: () { + process.kill(ProcessSignal.sigkill); + return -1; + }); +} + +void main() { + _startDaemon(); + runApp(const MainApp()); +} + ThemeData _materialTheme(ShadThemeData shadTheme) { return ThemeData( fontFamily: shadTheme.textTheme.family, @@ -46,10 +146,6 @@ ThemeData _materialTheme(ShadThemeData shadTheme) { ); } -void main() { - runApp(const MainApp()); -} - class MainApp extends StatefulWidget { const MainApp({super.key, this.apiClient}); @@ -59,8 +155,86 @@ class MainApp extends StatefulWidget { State createState() => _MainAppState(); } -class _MainAppState extends State { +class _MainAppState extends State with WidgetsBindingObserver { ThemeMode _themeMode = ThemeMode.system; + bool _daemonReady = false; + String? _error; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + if (widget.apiClient == null) { + _waitForDaemon(); + } else { + _daemonReady = true; + } + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.detached) { + _stopDaemon(); + } + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + _stopDaemon(); + super.dispose(); + } + + Future _waitForDaemon() async { + final url = Uri.parse('$defaultBaseUrl/v1/status'); + const attempts = 120; + final client = http.Client(); + + for (var i = 0; i < attempts; i++) { + try { + final response = await client.get(url).timeout(const Duration(seconds: 2)); + if (response.statusCode == 200) { + final body = jsonDecode(response.body); + if (body is Map) { + final runtime = body['runtime']; + if (runtime is Map && runtime['state'] == 'running') { + client.close(); + if (mounted) { + setState(() { + _daemonReady = true; + _error = null; + }); + } + return; + } + if (runtime is Map) { + final log = runtime['log']; + if (mounted) { + setState(() => _error = (log is String && log.isNotEmpty) ? log : null); + } + } + } + } + } on SocketException { + // expected while daemon is not up yet + } on TimeoutException { + // expected while daemon is starting + } on http.ClientException { + // expected while daemon is not up yet + } on FormatException catch (e) { + stderr.writeln('invalid daemon status response: $e'); + } catch (e) { + stderr.writeln('unexpected error while waiting for daemon: $e'); + } + await Future.delayed(const Duration(milliseconds: 500)); + } + client.close(); + if (mounted) { + setState(() { + _error = _error ?? 'Daemon did not become ready in time. Try restarting Calf.'; + }); + } + } @override Widget build(BuildContext context) { @@ -82,16 +256,31 @@ class _MainAppState extends State { ), child: ColoredBox( color: shadTheme.colorScheme.background, - child: child ?? const SizedBox.shrink(), + child: _daemonReady + ? (child ?? const SizedBox.shrink()) + : Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const CircularProgressIndicator(), + if (_error != null) ...[ + const SizedBox(height: 16), + Text(_error!, style: TextStyle(color: shadTheme.colorScheme.destructive)), + ], + ], + ), + ), ), ), ); }, - home: AppShell( - apiClient: widget.apiClient, - themeMode: _themeMode, - onThemeModeChanged: (mode) => setState(() => _themeMode = mode), - ), + home: _daemonReady + ? AppShell( + apiClient: widget.apiClient, + themeMode: _themeMode, + onThemeModeChanged: (mode) => setState(() => _themeMode = mode), + ) + : const SizedBox.shrink(), ); } } diff --git a/ui/lib/platform/open_url.dart b/ui/lib/platform/open_url.dart index 6962fef..95b895f 100644 --- a/ui/lib/platform/open_url.dart +++ b/ui/lib/platform/open_url.dart @@ -1,5 +1,9 @@ import 'dart:io'; +void openPort(int port) { + openExternalUrl('http://localhost:$port'); +} + Future openExternalUrl(String url) async { if (url.isEmpty) { return false; diff --git a/ui/lib/screens/compose_group_detail_screen.dart b/ui/lib/screens/compose_group_detail_screen.dart index 73a37a4..c404743 100644 --- a/ui/lib/screens/compose_group_detail_screen.dart +++ b/ui/lib/screens/compose_group_detail_screen.dart @@ -1,11 +1,11 @@ import 'dart:async'; -import 'dart:io' show Platform, Process; import 'package:flutter/material.dart' show Tooltip; import 'package:flutter/widgets.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; import 'package:ui/api/client.dart'; +import 'package:ui/platform/open_url.dart'; import 'package:ui/widgets/calf_button.dart'; import 'package:ui/widgets/hover_list_row.dart'; import 'package:ui/widgets/logs_panel.dart'; @@ -205,13 +205,6 @@ class _ComposeGroupDetailViewState extends State { }); } - void _openPort(int port) { - if (!Platform.isMacOS) { - return; - } - Process.run('open', ['http://localhost:$port']); - } - @override Widget build(BuildContext context) { final theme = ShadTheme.of(context); @@ -291,7 +284,7 @@ class _ComposeGroupDetailViewState extends State { onStart: (id) => _runAction(() => widget.apiClient.startContainer(id)), onStop: (id) => _runAction(() => widget.apiClient.stopContainer(id)), onRemove: (id) => _runAction(() => widget.apiClient.removeContainer(id)), - onOpenPort: _openPort, + onOpenPort: openPort, busy: _busy, ), ), diff --git a/ui/lib/screens/container_detail_screen.dart b/ui/lib/screens/container_detail_screen.dart index 1829f73..ea0d128 100644 --- a/ui/lib/screens/container_detail_screen.dart +++ b/ui/lib/screens/container_detail_screen.dart @@ -1,6 +1,5 @@ import 'dart:async'; import 'dart:convert'; -import 'dart:io' show Platform, Process; import 'package:fl_chart/fl_chart.dart'; import 'package:flutter/material.dart' show Divider, SelectableText; @@ -11,6 +10,7 @@ import 'package:web_socket_channel/web_socket_channel.dart'; import 'package:xterm/xterm.dart'; import 'package:ui/api/client.dart'; +import 'package:ui/platform/open_url.dart'; import 'package:ui/widgets/calf_button.dart'; import 'package:ui/widgets/files_panel.dart'; import 'package:ui/widgets/logs_panel.dart'; @@ -307,13 +307,6 @@ class _ContainerDetailViewState extends State { } } - void _openPort(int port) { - if (!Platform.isMacOS) { - return; - } - Process.run('open', ['http://localhost:$port']); - } - @override Widget build(BuildContext context) { final theme = ShadTheme.of(context); @@ -361,7 +354,7 @@ class _ContainerDetailViewState extends State { Text(_container.displayImage, style: theme.textTheme.muted), if (port != null) GestureDetector( - onTap: () => _openPort(port), + onTap: () => openPort(port), child: Text( '$port:$port', style: theme.textTheme.small.copyWith(color: theme.colorScheme.primary), diff --git a/ui/lib/screens/containers_screen.dart b/ui/lib/screens/containers_screen.dart index 69f43b9..148f43c 100644 --- a/ui/lib/screens/containers_screen.dart +++ b/ui/lib/screens/containers_screen.dart @@ -1,5 +1,4 @@ import 'dart:async'; -import 'dart:io' show Platform, Process; import 'dart:math' as math; import 'package:flutter/material.dart' show Tooltip; @@ -7,6 +6,7 @@ import 'package:flutter/widgets.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; import 'package:ui/api/client.dart'; +import 'package:ui/platform/open_url.dart'; import 'package:ui/screens/compose_group_detail_screen.dart'; import 'package:ui/screens/container_detail_screen.dart'; import 'package:ui/widgets/calf_button.dart'; @@ -190,13 +190,6 @@ class _ContainersScreenState extends State { }); } - void _openPort(int port) { - if (!Platform.isMacOS) { - return; - } - Process.run('open', ['http://localhost:$port']); - } - Future _loadGroupPreferences() async { final saved = await ContainerGroupPreferences.loadExpanded(); if (!mounted) { @@ -367,7 +360,7 @@ class _ContainersScreenState extends State { onStopAll: () => _runGroupAction(group.value, widget.apiClient.stopContainer, runningOnly: true), onRemoveAll: () => _runGroupAction(group.value, widget.apiClient.removeContainer), onOpen: _openContainer, - onOpenPort: _openPort, + onOpenPort: openPort, ), for (final container in layout.standalone) _ContainerTile( @@ -378,7 +371,7 @@ class _ContainersScreenState extends State { onStop: () => _runAction(() => widget.apiClient.stopContainer(container.id)), onRemove: () => _runAction(() => widget.apiClient.removeContainer(container.id)), onOpen: () => _openContainer(container), - onOpenPort: _openPort, + onOpenPort: openPort, ), ], ), diff --git a/ui/macos/Runner/Release.entitlements b/ui/macos/Runner/Release.entitlements index 38da9a9..f45066b 100644 --- a/ui/macos/Runner/Release.entitlements +++ b/ui/macos/Runner/Release.entitlements @@ -2,7 +2,7 @@ - com.apple.security.app-sandbox + com.apple.security.network.server com.apple.security.network.client diff --git a/ui/pubspec.yaml b/ui/pubspec.yaml index 0c35917..4cc6729 100644 --- a/ui/pubspec.yaml +++ b/ui/pubspec.yaml @@ -1,7 +1,7 @@ name: ui description: "Calf UI App." publish_to: 'none' -version: 0.6.0+1 +version: 0.7.0+1 environment: sdk: ^3.12.2