Compare commits

...

3 Commits

Author SHA1 Message Date
8e369ebd5a No terminal window on windows 2026-02-13 20:59:47 +01:00
a9e626393c Ignore exe files 2026-02-13 20:36:40 +01:00
1979fbf9f1 fix windows install script 2026-02-13 20:36:06 +01:00
7 changed files with 226 additions and 12 deletions

3
.gitignore vendored
View File

@@ -1 +1,2 @@
dist/
dist/
*.exe

View File

@@ -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

View File

@@ -8,6 +8,7 @@ This program runs on the users 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`.

View File

@@ -107,9 +107,9 @@ func registerWindowsScheduledTask(exePath, listen string, allow []string) error
ps := `
param(
[string]$ExePath,
[string]$Listen,
[string]$AllowJson
[string]$ExePath,
[string]$Listen,
[string]$AllowJson
)
$ErrorActionPreference = 'Stop'
$ServiceName = 'luxtools-client'
@@ -151,14 +151,30 @@ Register-ScheduledTask -TaskName $TaskName -InputObject $task -Force | Out-Null
try { Start-ScheduledTask -TaskName $TaskName | Out-Null } catch {}
`
psFile, err := os.CreateTemp("", "luxtools-client-install-*.ps1")
if err != nil {
return err
}
psPath := psFile.Name()
if _, err := psFile.WriteString(ps); err != nil {
_ = psFile.Close()
_ = os.Remove(psPath)
return err
}
if err := psFile.Close(); err != nil {
_ = os.Remove(psPath)
return err
}
defer os.Remove(psPath)
cmd := exec.Command("powershell.exe",
"-NoProfile",
"-NonInteractive",
"-ExecutionPolicy", "Bypass",
"-Command", ps,
exePath,
listen,
string(allowJSON),
"-File", psPath,
"-ExePath", exePath,
"-Listen", listen,
"-AllowJson", string(allowJSON),
)
out, err := cmd.CombinedOutput()
if err != nil {

105
internal/logging/logging.go Normal file
View File

@@ -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
}

View File

@@ -179,6 +179,32 @@ var settingsTemplate = template.Must(template.New("settings").Parse(`<!doctype h
</html>
`))
var logsTemplate = template.Must(template.New("logs").Parse(`<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>luxtools-client Logs</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; }
pre { background: #f7f7f7; padding: 0.75rem; border: 1px solid #ddd; overflow: auto; max-height: 70vh; }
.small { color: #666; font-size: 0.9rem; }
button { padding: 0.35rem 0.7rem; }
</style>
</head>
<body>
<h1>Logs</h1>
<p class="small">Log file: <code>{{ .LogPath }}</code></p>
<p class="small">Updated: <code>{{ .Updated }}</code></p>
<p>
<a href="/logs">View raw text</a>
<button type="button" onclick="location.reload()">Refresh</button>
</p>
<pre>{{ .LogText }}</pre>
</body>
</html>
`))
// 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)
}

53
main.go
View File

@@ -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 {