Add notifications and info page

This commit is contained in:
2026-01-07 09:50:36 +01:00
parent 950e488190
commit 0163d22f70
6 changed files with 196 additions and 8 deletions

127
main.go
View File

@@ -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(`<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>luxtools-client</title>
<style>
body { font-family: system-ui, sans-serif; margin: 1.25rem; }
code, pre { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; }
table { border-collapse: collapse; }
th, td { border-bottom: 1px solid #ddd; padding: 0.4rem 0.6rem; text-align: left; vertical-align: top; }
</style>
</head>
<body>
<h1>luxtools-client</h1>
<h2>Endpoints</h2>
<table>
<thead>
<tr><th>Path</th><th>Methods</th><th>Description</th></tr>
</thead>
<tbody>
{{- range .Endpoints }}
<tr>
<td><a href="{{ .Path }}"><code>{{ .Path }}</code></a></td>
<td><code>{{ .Methods }}</code></td>
<td>{{ .Description }}</td>
</tr>
{{- end }}
</tbody>
</table>
<h2>Info</h2>
<pre>{{ .InfoJSON }}</pre>
</body>
</html>
`))
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.