package main import ( "encoding/json" "errors" "flag" "fmt" "html" "html/template" "io" "log" "net" "net/http" "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, ",") }
func (a *allowList) Set(value string) error {
value = strings.TrimSpace(value)
if value == "" {
return nil
}
*a = append(*a, value)
return nil
}
type openRequest struct {
Path string `json:"path"`
}
type openResponse struct {
OK bool `json:"ok"`
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)
if len(os.Args) > 1 {
switch os.Args[1] {
case "install":
if err := runInstall(os.Args[2:], infoLog, errLog); err != nil {
errLog.Fatal(err)
}
return
case "uninstall":
if err := runUninstall(os.Args[2:], infoLog, errLog); err != nil {
errLog.Fatal(err)
}
return
}
}
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()
if !isLoopbackListenAddr(*listen) {
errLog.Fatalf("refusing to listen on non-loopback address: %s", *listen)
}
mux := http.NewServeMux()
var endpointDocs []endpointDoc
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)})
})
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
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
var req openRequest
switch r.Method {
case http.MethodGet:
req.Path = r.URL.Query().Get("path")
case http.MethodPost:
dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 32*1024))
dec.DisallowUnknownFields()
if err := dec.Decode(&req); err != nil {
errLog.Printf("/open bad-json method=%s err=%v dur=%s", r.Method, err, time.Since(start))
writeJSON(w, http.StatusBadRequest, openResponse{OK: false, Message: "invalid json"})
return
}
default:
errLog.Printf("/open method-not-allowed method=%s dur=%s", r.Method, time.Since(start))
writeJSON(w, http.StatusMethodNotAllowed, openResponse{OK: false, Message: "GET or POST required"})
return
}
rawPath = req.Path
target, err := normalizePath(req.Path)
if err != nil {
errLog.Printf("/open bad-path method=%s path=%q err=%v dur=%s", r.Method, rawPath, err, time.Since(start))
writeJSON(w, http.StatusBadRequest, openResponse{OK: false, Message: err.Error()})
return
}
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.
w.WriteHeader(http.StatusNoContent)
return
}
writeJSON(w, http.StatusOK, openResponse{OK: true, Message: "opened"})
})
srv := &http.Server{
Addr: *listen,
Handler: mux,
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 30 * time.Second,
}
infoLog.Printf("listening on http://%s", *listen)
infoLog.Printf("os=%s arch=%s", runtime.GOOS, runtime.GOARCH)
errLog.Fatal(srv.ListenAndServe())
}
func runInstall(args []string, infoLog, errLog *log.Logger) error {
fs := flag.NewFlagSet("install", flag.ContinueOnError)
fs.SetOutput(io.Discard)
listen := fs.String("listen", "127.0.0.1:8765", "listen address (host:port), should be loopback")
dryRun := fs.Bool("dry-run", false, "print/validate only; do not write files or register services")
var allowed allowList
fs.Var(&allowed, "allow", "allowed path prefix (repeatable); if none, any path is allowed")
if err := fs.Parse(args); err != nil {
return fmt.Errorf("install: %w", err)
}
if !installer.Supported() {
return fmt.Errorf("install: unsupported OS: %s", runtime.GOOS)
}
if !isLoopbackListenAddr(*listen) {
return fmt.Errorf("install: refusing non-loopback listen address: %s", *listen)
}
if err := installer.Install(installer.InstallOptions{Listen: *listen, Allow: []string(allowed), DryRun: *dryRun}); err != nil {
return err
}
if *dryRun {
infoLog.Printf("install dry-run OK")
return nil
}
infoLog.Printf("installed %s", installer.ServiceName)
return nil
}
func runUninstall(args []string, infoLog, errLog *log.Logger) error {
fs := flag.NewFlagSet("uninstall", flag.ContinueOnError)
fs.SetOutput(io.Discard)
keepConfig := fs.Bool("keep-config", false, "keep config on disk")
dryRun := fs.Bool("dry-run", false, "print/validate only; do not remove files or unregister services")
if err := fs.Parse(args); err != nil {
return fmt.Errorf("uninstall: %w", err)
}
if !installer.Supported() {
return fmt.Errorf("uninstall: unsupported OS: %s", runtime.GOOS)
}
if err := installer.Uninstall(installer.UninstallOptions{KeepConfig: *keepConfig, DryRun: *dryRun}); err != nil {
return err
}
if *dryRun {
infoLog.Printf("uninstall dry-run OK")
return nil
}
infoLog.Printf("uninstalled %s", installer.ServiceName)
return nil
}
func isLoopbackListenAddr(addr string) bool {
host, _, err := net.SplitHostPort(addr)
if err != nil {
return false
}
ip := net.ParseIP(host)
if ip == nil {
return host == "localhost"
}
return ip.IsLoopback()
}
func withCORS(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
if origin != "" {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Vary", "Origin")
} else {
w.Header().Set("Access-Control-Allow-Origin", "*")
}
w.Header().Set("Access-Control-Allow-Methods", "POST, GET, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
}
func normalizePath(input string) (string, error) {
p := strings.TrimSpace(input)
if p == "" {
return "", errors.New("missing path")
}
// Accept file:// URLs.
if strings.HasPrefix(strings.ToLower(p), "file://") {
p = strings.TrimPrefix(p, "file://")
// file:///C:/... becomes /C:/... (strip one leading slash)
p = strings.TrimPrefix(p, "/")
p = strings.TrimPrefix(p, "/")
p = strings.TrimPrefix(p, "/")
p = strings.ReplaceAll(p, "/", string(os.PathSeparator))
}
p = filepath.Clean(p)
if !filepath.IsAbs(p) {
return "", errors.New("path must be absolute")
}
// Ensure path exists.
st, err := os.Stat(p)
if err != nil {
return "", fmt.Errorf("path not found")
}
if !st.IsDir() {
// If a file is provided, open its containing folder.
p = filepath.Dir(p)
}
return p, nil
}
func isAllowed(path string, allowed []string) bool {
path = filepath.Clean(path)
for _, a := range allowed {
a = filepath.Clean(a)
if a == "." || a == string(os.PathSeparator) {
return true
}
// Case-insensitive on Windows.
if runtime.GOOS == "windows" {
if strings.HasPrefix(strings.ToLower(path), strings.ToLower(a)) {
return true
}
} else {
if strings.HasPrefix(path, a) {
return true
}
}
}
return false
}
func writeJSON(w http.ResponseWriter, status int, resp openResponse) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(resp)
}