From 8e369ebd5aed145f79184a75e14c11189b0dc3b1 Mon Sep 17 00:00:00 2001 From: luxick Date: Fri, 13 Feb 2026 20:59:47 +0100 Subject: [PATCH] No terminal window on windows --- Makefile | 5 +- README.md | 11 ++++ internal/logging/logging.go | 105 ++++++++++++++++++++++++++++++++++++ internal/web/templates.go | 31 +++++++++++ main.go | 53 +++++++++++++++++- 5 files changed, 201 insertions(+), 4 deletions(-) create mode 100644 internal/logging/logging.go diff --git a/Makefile b/Makefile index e579dc0..c4e0b7c 100644 --- a/Makefile +++ b/Makefile @@ -6,6 +6,7 @@ DIST_DIR := dist NATIVE_GOOS := $(shell go env GOOS) NATIVE_GOARCH := $(shell go env GOARCH) NATIVE_EXT := $(if $(filter windows,$(NATIVE_GOOS)),.exe,) +WINDOWS_GUI_LDFLAGS := $(if $(filter windows,$(NATIVE_GOOS)),-ldflags "-H=windowsgui",) # Native (current platform) NATIVE_EXE := $(APP_NAME)$(if $(filter windows,$(OS)),.exe,) @@ -23,7 +24,7 @@ $(DIST_DIR): build: $(DIST_DIR) @echo "Building $(APP_NAME) for current platform..." - go build -trimpath -o $(DIST_DIR)/$(APP_NAME)$(NATIVE_EXT) . + go build -trimpath $(WINDOWS_GUI_LDFLAGS) -o $(DIST_DIR)/$(APP_NAME)$(NATIVE_EXT) . build-linux: $(DIST_DIR) @echo "Cross-compiling $(APP_NAME) for linux/amd64..." @@ -31,7 +32,7 @@ build-linux: $(DIST_DIR) build-windows: $(DIST_DIR) @echo "Cross-compiling $(APP_NAME) for windows/amd64..." - GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build -trimpath -o $(DIST_DIR)/$(APP_NAME)-windows-amd64.exe . + GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build -trimpath -ldflags "-H=windowsgui" -o $(DIST_DIR)/$(APP_NAME)-windows-amd64.exe . build-all: build-linux build-windows diff --git a/README.md b/README.md index ddef0ff..2dc42ea 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ This program runs on the user’s machine and listens on a loopback address only - Exposes `GET /health` for a simple health check. - Exposes `GET /open?path=...` and `POST /open` to open a folder. +- Exposes `GET /control` (HTML) and `GET /logs` (text) to view recent logs. - Normalizes and validates the requested path: - Accepts absolute paths only. - If a file path is provided, it opens the containing directory. @@ -150,6 +151,8 @@ Notes: - Installs to `%LOCALAPPDATA%\luxtools-client\luxtools-client.exe` - Stores config in `%LOCALAPPDATA%\luxtools-client\config.json` - Re-running `install` updates the EXE in place and refreshes the task. +- Windows builds use the GUI subsystem, so the app starts without a console window. +- Logs are written next to `config.json` and can be viewed at `http://127.0.0.1:8765/control` or `http://127.0.0.1:8765/logs`. ## API @@ -195,6 +198,14 @@ curl -i \ 'http://127.0.0.1:8765/open?path=/tmp' ``` +### `GET /logs` + +Returns recent log output as plain text. + +### `GET /control` + +Shows a simple HTML page with recent log output. + ## OS support - Linux: uses `xdg-open`. diff --git a/internal/logging/logging.go b/internal/logging/logging.go new file mode 100644 index 0000000..f581d5e --- /dev/null +++ b/internal/logging/logging.go @@ -0,0 +1,105 @@ +package logging + +import ( + "io" + "log" + "os" + "path/filepath" + "sync" + + "luxtools-client/internal/config" +) + +const ( + logBufferMaxBytes = 256 * 1024 + logFileMaxBytes = 5 * 1024 * 1024 +) + +// Buffer stores recent log output for control pages. +type Buffer struct { + mu sync.Mutex + buf []byte + max int +} + +func newBuffer(max int) *Buffer { + return &Buffer{max: max} +} + +func (b *Buffer) Write(p []byte) (int, error) { + b.mu.Lock() + defer b.mu.Unlock() + + if len(p) > b.max { + p = p[len(p)-b.max:] + } + b.buf = append(b.buf, p...) + if len(b.buf) > b.max { + b.buf = b.buf[len(b.buf)-b.max:] + } + return len(p), nil +} + +// Bytes returns a copy of the buffered log content. +func (b *Buffer) Bytes() []byte { + b.mu.Lock() + defer b.mu.Unlock() + if len(b.buf) == 0 { + return nil + } + copyBuf := make([]byte, len(b.buf)) + copy(copyBuf, b.buf) + return copyBuf +} + +func logFilePath() (string, error) { + configPath, err := config.ConfigPath() + if err != nil { + return "", err + } + return filepath.Join(filepath.Dir(configPath), "luxtools-client.log"), nil +} + +func prepareLogFile(path string, maxBytes int64) (*os.File, error) { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return nil, err + } + if info, err := os.Stat(path); err == nil && info.Size() > maxBytes { + _ = os.Remove(path + ".old") + _ = os.Rename(path, path+".old") + } + return os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644) +} + +// Setup builds loggers, a buffer, and optional file logging. +func Setup() (*log.Logger, *log.Logger, *Buffer, string, func()) { + logStore := newBuffer(logBufferMaxBytes) + logPath, logErr := logFilePath() + var logFile *os.File + if logErr == nil { + if f, err := prepareLogFile(logPath, logFileMaxBytes); err == nil { + logFile = f + } else { + logPath = "" + } + } else { + logPath = "" + } + + infoWriters := []io.Writer{logStore, os.Stdout} + errWriters := []io.Writer{logStore, os.Stderr} + if logFile != nil { + infoWriters = append(infoWriters, logFile) + errWriters = append(errWriters, logFile) + } + + infoLog := log.New(io.MultiWriter(infoWriters...), "", log.LstdFlags) + errLog := log.New(io.MultiWriter(errWriters...), "ERROR: ", log.LstdFlags) + + cleanup := func() {} + if logFile != nil { + cleanup = func() { _ = logFile.Close() } + } + + return infoLog, errLog, logStore, logPath, cleanup +} diff --git a/internal/web/templates.go b/internal/web/templates.go index 88bd2cd..d0a2b62 100644 --- a/internal/web/templates.go +++ b/internal/web/templates.go @@ -179,6 +179,32 @@ var settingsTemplate = template.Must(template.New("settings").Parse(` `)) +var logsTemplate = template.Must(template.New("logs").Parse(` + + + + luxtools-client Logs + + + +

Logs

+

Log file: {{ .LogPath }}

+

Updated: {{ .Updated }}

+

+ View raw text + +

+
{{ .LogText }}
+ + +`)) + // RenderIndex renders the main index page. func RenderIndex(w io.Writer, data any) error { return indexTemplate.Execute(w, data) @@ -188,3 +214,8 @@ func RenderIndex(w io.Writer, data any) error { func RenderSettings(w io.Writer) error { return settingsTemplate.Execute(w, nil) } + +// RenderLogs renders the logs control page. +func RenderLogs(w io.Writer, data any) error { + return logsTemplate.Execute(w, data) +} diff --git a/main.go b/main.go index 07d917c..1947919 100644 --- a/main.go +++ b/main.go @@ -21,6 +21,7 @@ import ( "luxtools-client/internal/config" "luxtools-client/internal/installer" + "luxtools-client/internal/logging" "luxtools-client/internal/notify" "luxtools-client/internal/openfolder" "luxtools-client/internal/web" @@ -129,8 +130,8 @@ func buildInfoPayload() map[string]any { } func main() { - infoLog := log.New(os.Stdout, "", log.LstdFlags) - errLog := log.New(os.Stderr, "ERROR: ", log.LstdFlags) + infoLog, errLog, logStore, logPath, cleanup := logging.Setup() + defer cleanup() if len(os.Args) > 1 { switch os.Args[1] { @@ -232,6 +233,54 @@ func main() { } }) + register(mux, &endpointDocs, "/control", "GET, OPTIONS", "Logs control 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 + } + + displayPath := logPath + if displayPath == "" { + displayPath = "unavailable" + } + + data := struct { + LogPath string + LogText template.HTML + Updated string + }{ + LogPath: displayPath, + LogText: template.HTML(html.EscapeString(string(logStore.Bytes()))), + Updated: time.Now().Format(time.RFC3339), + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := web.RenderLogs(w, data); err != nil { + errLog.Printf("/control template error=%v", err) + } + }) + + register(mux, &endpointDocs, "/logs", "GET, OPTIONS", "Recent log output (text)", 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", "text/plain; charset=utf-8") + w.Header().Set("Cache-Control", "no-store") + _, _ = w.Write(logStore.Bytes()) + }) + register(mux, &endpointDocs, "/settings/config", "GET, POST, PUT, OPTIONS", "Read/update path alias config (JSON)", func(w http.ResponseWriter, r *http.Request) { withCORS(w, r) if r.Method == http.MethodOptions {