diff --git a/.vscode/launch.json b/.vscode/launch.json index 94fa3dd..e70b316 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -8,7 +8,7 @@ "mode": "auto", "program": "${workspaceFolder}", "cwd": "${workspaceFolder}", - "args": [], + "args": ["-debug-notify"], "console": "integratedTerminal" }, { @@ -18,7 +18,7 @@ "mode": "auto", "program": "${workspaceFolder}", "cwd": "${workspaceFolder}", - "args": ["-listen", "127.0.0.1:9000"], + "args": ["-debug-notify", "-listen", "127.0.0.1:9000"], "console": "integratedTerminal" }, { diff --git a/go.mod b/go.mod index 07c5d96..52d9a45 100644 --- a/go.mod +++ b/go.mod @@ -2,6 +2,19 @@ module luxtools-client go 1.22 -require github.com/godbus/dbus/v5 v5.2.2 +require ( + github.com/gen2brain/beeep v0.11.2 + github.com/godbus/dbus/v5 v5.2.2 +) -require golang.org/x/sys v0.27.0 // indirect +require ( + git.sr.ht/~jackmordaunt/go-toast v1.1.2 // indirect + github.com/esiqveland/notify v0.13.3 // indirect + github.com/go-ole/go-ole v1.3.0 // indirect + github.com/jackmordaunt/icns/v3 v3.0.1 // indirect + github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect + github.com/sergeymakinen/go-bmp v1.0.0 // indirect + github.com/sergeymakinen/go-ico v1.0.0-beta.0 // indirect + github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af // indirect + golang.org/x/sys v0.30.0 // indirect +) diff --git a/go.sum b/go.sum index 5334971..99a1c79 100644 --- a/go.sum +++ b/go.sum @@ -1,4 +1,40 @@ +git.sr.ht/~jackmordaunt/go-toast v1.1.2 h1:/yrfI55LRt1M7H1vkaw+NaH1+L1CDxrqDltwm5euVuE= +git.sr.ht/~jackmordaunt/go-toast v1.1.2/go.mod h1:jA4OqHKTQ4AFBdwrSnwnskUIIS3HYzlJSgdzCKqfavo= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +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/esiqveland/notify v0.13.3 h1:QCMw6o1n+6rl+oLUfg8P1IIDSFsDEb2WlXvVvIJbI/o= +github.com/esiqveland/notify v0.13.3/go.mod h1:hesw/IRYTO0x99u1JPweAl4+5mwXJibQVUcP0Iu5ORE= +github.com/gen2brain/beeep v0.11.2 h1:+KfiKQBbQCuhfJFPANZuJ+oxsSKAYNe88hIpJuyKWDA= +github.com/gen2brain/beeep v0.11.2/go.mod h1:jQVvuwnLuwOcdctHn/uyh8horSBNJ8uGb9Cn2W4tvoc= +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.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= -golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s= -golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +github.com/jackmordaunt/icns/v3 v3.0.1 h1:xxot6aNuGrU+lNgxz5I5H0qSeCjNKp8uTXB1j8D4S3o= +github.com/jackmordaunt/icns/v3 v3.0.1/go.mod h1:5sHL59nqTd2ynTnowxB/MDQFhKNqkK8X687uKNygaSQ= +github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= +github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= +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/sergeymakinen/go-bmp v1.0.0 h1:SdGTzp9WvCV0A1V0mBeaS7kQAwNLdVJbmHlqNWq0R+M= +github.com/sergeymakinen/go-bmp v1.0.0/go.mod h1:/mxlAQZRLxSvJFNIEGGLBE/m40f3ZnUifpgVDlcUIEY= +github.com/sergeymakinen/go-ico v1.0.0-beta.0 h1:m5qKH7uPKLdrygMWxbamVn+tl2HfiA3K6MFJw4GfZvQ= +github.com/sergeymakinen/go-ico v1.0.0-beta.0/go.mod h1:wQ47mTczswBO5F0NoDt7O0IXgnV4Xy3ojrroMQzyhUk= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af h1:6yITBqGTE2lEeTPG04SN9W+iWHCRyHqlVYILiSXziwk= +github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af/go.mod h1:4F09kP5F+am0jAwlQLddpoMDM+iewkxxt6nxUQ5nq5o= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= +golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +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/internal/notify/info.png b/internal/notify/info.png new file mode 100755 index 0000000..09e98be Binary files /dev/null and b/internal/notify/info.png differ diff --git a/internal/notify/notify.go b/internal/notify/notify.go new file mode 100644 index 0000000..939ca10 --- /dev/null +++ b/internal/notify/notify.go @@ -0,0 +1,16 @@ +package notify + +import ( + _ "embed" + + "github.com/gen2brain/beeep" +) + +//go:embed info.png +var icon []byte + +// Show shows a desktop notification if possible. +// It must never panic and callers should treat it as fire-and-forget. +func Show(title, message string) { + _ = beeep.Notify(title, message, icon) +} diff --git a/main.go b/main.go index 5b726f3..9e303a5 100644 --- a/main.go +++ b/main.go @@ -5,6 +5,8 @@ import ( "errors" "flag" "fmt" + "html" + "html/template" "io" "log" "net" @@ -12,13 +14,65 @@ import ( "os" "path/filepath" "runtime" + "runtime/debug" "strings" "time" "luxtools-client/internal/installer" + "luxtools-client/internal/notify" "luxtools-client/internal/openfolder" ) +var version = "dev" + +type endpointDoc struct { + Path string + Methods string + Description string +} + +func register(mux *http.ServeMux, docs *[]endpointDoc, path, methods, description string, handler http.HandlerFunc) { + mux.HandleFunc(path, handler) + *docs = append(*docs, endpointDoc{Path: path, Methods: methods, Description: description}) +} + +var indexTemplate = template.Must(template.New("index").Parse(` + +
+ +| Path | Methods | Description |
|---|---|---|
{{ .Path }} |
+ {{ .Methods }} |
+ {{ .Description }} | +
{{ .InfoJSON }}
+
+
+`))
+
type allowList []string
func (a *allowList) String() string { return strings.Join(*a, ",") }
@@ -40,6 +94,27 @@ type openResponse struct {
Message string `json:"message"`
}
+func buildInfoPayload() map[string]any {
+ var deps []map[string]string
+ if bi, ok := debug.ReadBuildInfo(); ok {
+ for _, d := range bi.Deps {
+ if d == nil {
+ continue
+ }
+ deps = append(deps, map[string]string{"path": d.Path, "version": d.Version})
+ }
+ }
+ return map[string]any{
+ "ok": true,
+ "time": time.Now().Format(time.RFC3339),
+ "os": runtime.GOOS,
+ "arch": runtime.GOARCH,
+ "version": version,
+ "goVersion": runtime.Version(),
+ "deps": deps,
+ }
+}
+
func main() {
infoLog := log.New(os.Stdout, "", log.LstdFlags)
errLog := log.New(os.Stderr, "ERROR: ", log.LstdFlags)
@@ -60,6 +135,7 @@ func main() {
}
listen := flag.String("listen", "127.0.0.1:8765", "listen address (host:port), should be loopback")
+ debugNotify := flag.Bool("debug-notify", false, "debug: show OS notifications on successful actions")
var allowed allowList
flag.Var(&allowed, "allow", "allowed path prefix (repeatable); if none, any path is allowed")
flag.Parse()
@@ -69,14 +145,56 @@ func main() {
}
mux := http.NewServeMux()
+ var endpointDocs []endpointDoc
- mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
+ register(mux, &endpointDocs, "/health", "GET", "Simple health check (JSON)", func(w http.ResponseWriter, r *http.Request) {
withCORS(w, r)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "time": time.Now().Format(time.RFC3339)})
})
- mux.HandleFunc("/open", func(w http.ResponseWriter, r *http.Request) {
+ register(mux, &endpointDocs, "/info", "GET, OPTIONS", "Detailed client info (JSON)", func(w http.ResponseWriter, r *http.Request) {
+ withCORS(w, r)
+ if r.Method == http.MethodOptions {
+ w.WriteHeader(http.StatusNoContent)
+ return
+ }
+ if r.Method != http.MethodGet {
+ w.WriteHeader(http.StatusMethodNotAllowed)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(buildInfoPayload())
+ })
+
+ register(mux, &endpointDocs, "/", "GET, OPTIONS", "Human-friendly status page", func(w http.ResponseWriter, r *http.Request) {
+ withCORS(w, r)
+ if r.Method == http.MethodOptions {
+ w.WriteHeader(http.StatusNoContent)
+ return
+ }
+ if r.Method != http.MethodGet {
+ w.WriteHeader(http.StatusMethodNotAllowed)
+ return
+ }
+
+ payload := buildInfoPayload()
+ payloadJSON, _ := json.MarshalIndent(payload, "", " ")
+ data := struct {
+ Endpoints []endpointDoc
+ InfoJSON template.HTML
+ }{
+ Endpoints: endpointDocs,
+ InfoJSON: template.HTML(html.EscapeString(string(payloadJSON))),
+ }
+
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ if err := indexTemplate.Execute(w, data); err != nil {
+ errLog.Printf("/ index-template error=%v", err)
+ }
+ })
+
+ register(mux, &endpointDocs, "/open", "GET, POST, OPTIONS", "Open a folder in the OS file manager", func(w http.ResponseWriter, r *http.Request) {
withCORS(w, r)
start := time.Now()
var rawPath string
@@ -114,16 +232,21 @@ func main() {
if len(allowed) > 0 && !isAllowed(target, allowed) {
errLog.Printf("/open forbidden method=%s path=%q normalized=%q dur=%s", r.Method, rawPath, target, time.Since(start))
+ notify.Show("luxtools-client", fmt.Sprintf("Refused to open (not allowed): %s", target))
writeJSON(w, http.StatusForbidden, openResponse{OK: false, Message: "path not allowed"})
return
}
if err := openfolder.OpenLocation(target); err != nil {
errLog.Printf("/open open-failed method=%s path=%q normalized=%q err=%v dur=%s", r.Method, rawPath, target, err, time.Since(start))
+ notify.Show("luxtools-client", fmt.Sprintf("Failed to open: %s (%v)", target, err))
writeJSON(w, http.StatusInternalServerError, openResponse{OK: false, Message: err.Error()})
return
}
infoLog.Printf("/open opened method=%s path=%q normalized=%q dur=%s", r.Method, rawPath, target, time.Since(start))
+ if *debugNotify {
+ notify.Show("luxtools-client", fmt.Sprintf("Opened: %s", target))
+ }
if r.Method == http.MethodGet {
// For GET callers (image-ping), a 204 avoids console noise from non-image responses.