diff --git a/Makefile b/Makefile index 39c78c9..5d154eb 100644 --- a/Makefile +++ b/Makefile @@ -2,6 +2,7 @@ APP := imsg-bridge CLI := imsg-bridge-cli VERSION := $(shell tr -d '\n' < VERSION) BUILD_LDFLAGS := -X github.com/kacy/imsg-bridge/internal/buildinfo.Version=$(VERSION) +IMSG_BIN ?= $(shell [ -x ../imsg/bin/imsg ] && printf '%s' ../imsg/bin/imsg || printf '%s' imsg) .PHONY: build build-cli test fmt run clean dist release release-patch release-minor version web-install web-dev web-build web-test @@ -20,7 +21,7 @@ fmt: gofmt -w ./cmd ./internal run: - go run -ldflags "$(BUILD_LDFLAGS)" ./cmd/imsg-bridge + go run -ldflags "$(BUILD_LDFLAGS)" ./cmd/imsg-bridge -imsg-bin "$(IMSG_BIN)" clean: rm -rf bin dist web/dist web/*.tsbuildinfo web/vite.config.js web/vite.config.d.ts web/tailwind.config.js web/tailwind.config.d.ts diff --git a/README.md b/README.md index 550ef4c..94061c7 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,8 @@ still landing: - [`imsg`](https://github.com/steipete/imsg): `brew install imsg` - [tailscale](https://tailscale.com/) installed and connected +for local bridge work, the daemon also accepts `IMSGBRIDGE_IMSG_BIN=/path/to/imsg` or `-imsg-bin /path/to/imsg` if you need to point at a patched checkout before upstream catches up. + ## quick start ```sh diff --git a/cmd/imsg-bridge/main.go b/cmd/imsg-bridge/main.go index 80f598c..e2e1843 100644 --- a/cmd/imsg-bridge/main.go +++ b/cmd/imsg-bridge/main.go @@ -29,12 +29,14 @@ import ( func main() { var cfg api.Config var dataDir string + var imsgBinary string var socketPath string flag.StringVar(&cfg.ListenAddr, "listen", ":8443", "http listen address") flag.StringVar(&cfg.ServerName, "server-name", hostname(), "server name") flag.StringVar(&cfg.TailscaleIP, "tailscale-ip", "", "override detected tailscale ip") flag.StringVar(&dataDir, "data-dir", defaultDataDir(), "data directory") + flag.StringVar(&imsgBinary, "imsg-bin", "", "path to the imsg binary") flag.StringVar(&socketPath, "socket", "", "control socket path") flag.Parse() @@ -58,7 +60,7 @@ func main() { log.Fatal(err) } - runner := imsg.NewRunner("") + runner := imsg.NewRunner(imsgBinary) hub := events.NewHub() stateStore := store.New(filepath.Join(dataDir, "config.json")) state, err := stateStore.Load() @@ -154,6 +156,7 @@ func main() { }() log.Printf("serving https on %s", cfg.ListenAddr) + log.Printf("imsg binary %s", runner.Binary()) log.Printf("control socket %s", socketPath) log.Printf("tls fingerprint %s", material.Fingerprint) logPairingWarnings(cfg.ListenAddr, cfg.TailscaleIP, pairHost) diff --git a/internal/imsg/runner.go b/internal/imsg/runner.go index 2c452b5..c121eff 100644 --- a/internal/imsg/runner.go +++ b/internal/imsg/runner.go @@ -7,7 +7,9 @@ import ( "errors" "fmt" "io" + "os" "os/exec" + "path/filepath" "strconv" "strings" ) @@ -19,11 +21,56 @@ type Runner struct { } func NewRunner(binary string) *Runner { - if strings.TrimSpace(binary) == "" { - binary = "imsg" + return &Runner{binary: ResolveBinary(binary)} +} + +func ResolveBinary(binary string) string { + if binary := strings.TrimSpace(binary); binary != "" { + return binary + } + + if binary := strings.TrimSpace(os.Getenv("IMSGBRIDGE_IMSG_BIN")); binary != "" { + return binary + } + + for _, candidate := range defaultBinaryCandidates() { + if isExecutableFile(candidate) { + return candidate + } + } + + return "imsg" +} + +func (r *Runner) Binary() string { + return r.binary +} + +func defaultBinaryCandidates() []string { + candidates := make([]string, 0, 3) + + if cwd, err := os.Getwd(); err == nil { + candidates = append(candidates, filepath.Join(cwd, "..", "imsg", "bin", "imsg")) + } + + if exePath, err := os.Executable(); err == nil { + exeDir := filepath.Dir(exePath) + candidates = append(candidates, + filepath.Join(exeDir, "..", "imsg", "bin", "imsg"), + filepath.Join(exeDir, "..", "..", "imsg", "bin", "imsg"), + ) + } + + return candidates +} + +func isExecutableFile(path string) bool { + info, err := os.Stat(path) + if err != nil || info.IsDir() { + return false } - return &Runner{binary: binary} + return info.Mode()&0o111 != 0 } func (r *Runner) Version(ctx context.Context) (string, error) { diff --git a/internal/imsg/runner_test.go b/internal/imsg/runner_test.go new file mode 100644 index 0000000..90d8e3c --- /dev/null +++ b/internal/imsg/runner_test.go @@ -0,0 +1,79 @@ +package imsg + +import ( + "os" + "path/filepath" + "testing" +) + +func TestResolveBinaryPrefersExplicitPath(t *testing.T) { + t.Setenv("IMSGBRIDGE_IMSG_BIN", "/tmp/from-env") + + if got := ResolveBinary("/tmp/from-flag"); got != "/tmp/from-flag" { + t.Fatalf("expected explicit path, got %q", got) + } +} + +func TestResolveBinaryPrefersEnvVar(t *testing.T) { + t.Setenv("IMSGBRIDGE_IMSG_BIN", "/tmp/from-env") + + if got := ResolveBinary(""); got != "/tmp/from-env" { + t.Fatalf("expected env path, got %q", got) + } +} + +func TestResolveBinaryFindsSiblingCheckoutFromWorkingDir(t *testing.T) { + root := t.TempDir() + bridgeDir := filepath.Join(root, "imsg-bridge") + imsgDir := filepath.Join(root, "imsg", "bin") + + if err := os.MkdirAll(bridgeDir, 0o755); err != nil { + t.Fatalf("mkdir bridge dir: %v", err) + } + if err := os.MkdirAll(imsgDir, 0o755); err != nil { + t.Fatalf("mkdir imsg dir: %v", err) + } + + imsgPath := filepath.Join(imsgDir, "imsg") + if err := os.WriteFile(imsgPath, []byte("#!/bin/sh\n"), 0o755); err != nil { + t.Fatalf("write imsg binary: %v", err) + } + + prevWD, err := os.Getwd() + if err != nil { + t.Fatalf("getwd: %v", err) + } + t.Cleanup(func() { + if chdirErr := os.Chdir(prevWD); chdirErr != nil { + t.Fatalf("restore wd: %v", chdirErr) + } + }) + + if err := os.Chdir(bridgeDir); err != nil { + t.Fatalf("chdir: %v", err) + } + + t.Setenv("IMSGBRIDGE_IMSG_BIN", "") + + got := ResolveBinary("") + resolvedGot, err := filepath.EvalSymlinks(got) + if err != nil { + t.Fatalf("resolve got path: %v", err) + } + resolvedWant, err := filepath.EvalSymlinks(imsgPath) + if err != nil { + t.Fatalf("resolve want path: %v", err) + } + + if resolvedGot != resolvedWant { + t.Fatalf("expected sibling checkout path %q, got %q", resolvedWant, resolvedGot) + } +} + +func TestResolveBinaryFallsBackToPathLookup(t *testing.T) { + t.Setenv("IMSGBRIDGE_IMSG_BIN", "") + + if got := ResolveBinary(""); got != "imsg" { + t.Fatalf("expected default binary name, got %q", got) + } +}