Allow customizing companion commands

This commit is contained in:
2026-05-08 21:25:41 +02:00
parent 7209aebc62
commit 30b5e36cd7
11 changed files with 317 additions and 59 deletions
+4 -3
View File
@@ -2,6 +2,7 @@ NAS := luxick@192.168.3.3
COMPANION_WIN := companion/datascape-companion-windows-amd64.exe
COMPANION_LIN := companion/datascape-companion-linux-amd64
COMPANION_SRCS := $(wildcard cmd/companion/*.go) $(wildcard cmd/companion/*.html) go.mod go.sum
.PHONY: deploy companion companion-windows companion-linux companion-release
@@ -9,10 +10,10 @@ COMPANION_LIN := companion/datascape-companion-linux-amd64
# before `go build .` so embed.FS picks them up.
companion-release: $(COMPANION_WIN) $(COMPANION_LIN)
$(COMPANION_WIN):
GOOS=windows GOARCH=amd64 go build -o $@ ./cmd/companion
$(COMPANION_WIN): $(COMPANION_SRCS)
GOOS=windows GOARCH=amd64 go build -ldflags="-H windowsgui" -o $@ ./cmd/companion
$(COMPANION_LIN):
$(COMPANION_LIN): $(COMPANION_SRCS)
GOOS=linux GOARCH=amd64 go build -o $@ ./cmd/companion
companion-windows: $(COMPANION_WIN)
+3 -2
View File
@@ -136,15 +136,16 @@
}
function wireRevealButton() {
var btn = document.querySelector('[data-companion-reveal]');
if (!btn) return;
if (!state.available) return;
var btns = document.querySelectorAll('[data-companion-reveal]');
btns.forEach(function (btn) {
btn.hidden = false;
btn.addEventListener('click', function () {
var rel = wikiPathFromHref(window.location.pathname);
if (rel === null) rel = '';
companionGET('/open-folder', { path: rel }).catch(function () { });
});
});
}
function probeStatus() {
+1 -1
View File
@@ -8,7 +8,7 @@
<div class="content">{{.SpecialContent}}</div>
{{end}}
{{if .Entries}}
<h2 id="files">Files</h2>
<h2 id="files">Files <button class="btn btn-small" data-companion-reveal hidden title="Open folder in file manager">open</button></h2>
<div class="listing">
{{range .Entries}}
<div class="listing-item" data-path="{{.URL}}">
+96
View File
@@ -0,0 +1,96 @@
package main
import (
"errors"
"fmt"
"os/exec"
"runtime"
"strings"
)
// commandDefaults holds the per-OS open-command templates used when the user
// hasn't overridden them in the config.
type commandDefaults struct {
OpenFile string
OpenFolder string
}
func defaultCommands() commandDefaults {
if runtime.GOOS == "windows" {
return commandDefaults{
OpenFile: `cmd /c start "" "{path}"`,
OpenFolder: `explorer.exe "{path}"`,
}
}
return commandDefaults{
OpenFile: `xdg-open "{path}"`,
OpenFolder: `xdg-open "{path}"`,
}
}
// resolveOpenCommand returns the user-configured command if non-blank, else
// the platform default.
func resolveOpenCommand(configured, fallback string) string {
if strings.TrimSpace(configured) != "" {
return configured
}
return fallback
}
// runOpenCommand tokenizes template, substitutes {path} with the resolved
// path (appending it if the placeholder is missing), and starts the command.
func runOpenCommand(template, path string) error {
tokens, err := tokenizeCommand(template)
if err != nil {
return fmt.Errorf("parse command: %w", err)
}
if len(tokens) == 0 {
return errors.New("command is empty")
}
sawPath := false
for i, t := range tokens {
if strings.Contains(t, "{path}") {
tokens[i] = strings.ReplaceAll(t, "{path}", path)
sawPath = true
}
}
if !sawPath {
tokens = append(tokens, path)
}
return exec.Command(tokens[0], tokens[1:]...).Start()
}
// tokenizeCommand splits a command-line string into argv tokens, honouring
// double-quoted segments. An empty pair "" yields an empty argument — needed
// for Windows `cmd /c start "" file`, where the empty quotes are the title.
func tokenizeCommand(s string) ([]string, error) {
var tokens []string
var cur strings.Builder
inQuote := false
inToken := false
for i := 0; i < len(s); i++ {
c := s[i]
if c == '"' {
inQuote = !inQuote
inToken = true
continue
}
if !inQuote && (c == ' ' || c == '\t') {
if inToken {
tokens = append(tokens, cur.String())
cur.Reset()
inToken = false
}
continue
}
cur.WriteByte(c)
inToken = true
}
if inQuote {
return nil, errors.New("unclosed quote")
}
if inToken {
tokens = append(tokens, cur.String())
}
return tokens, nil
}
+2
View File
@@ -15,6 +15,8 @@ type config struct {
WikiRoot string `json:"wikiRoot"`
AllowedOrigins []string `json:"allowedOrigins"`
Port int `json:"port,omitempty"`
OpenFileCommand string `json:"openFileCommand,omitempty"`
OpenFolderCommand string `json:"openFolderCommand,omitempty"`
}
// configPath returns the platform-conventional config path.
+63
View File
@@ -49,6 +49,17 @@
margin-top: 1.25rem;
}
button:hover { background: var(--primary-hover); }
.btn-small {
padding: 0.4rem 0.75rem;
font-size: 0.8rem;
margin-top: 0;
}
.input-row {
display: flex;
gap: 0.5rem;
align-items: stretch;
}
.input-row input { flex: 1; }
.notice {
background: var(--bg-panel);
border-left: 3px solid var(--secondary);
@@ -58,6 +69,23 @@
.meta { margin-top: 2rem; font-size: 0.8rem; color: var(--text-muted); border-top: 1px dashed var(--secondary); padding-top: 0.75rem; }
.meta div { margin: 0.2rem 0; }
code { color: var(--link); word-break: break-all; }
.log {
margin-top: 2rem;
border-top: 1px dashed var(--secondary);
padding-top: 0.75rem;
}
.log h2 { font-size: 0.95rem; margin: 0 0 0.5rem; }
.log pre {
background: var(--bg-panel);
border: 1px solid var(--secondary);
margin: 0;
padding: 0.5rem 0.75rem;
max-height: 20rem;
overflow: auto;
font-size: 0.8rem;
white-space: pre-wrap;
word-break: break-all;
}
</style>
</head>
<body>
@@ -78,13 +106,48 @@
<textarea id="allowedOrigins" name="allowedOrigins" placeholder="https://wiki.example.lan&#10;http://192.168.1.10:8080">{{.AllowedOrigins}}</textarea>
<div class="help">One origin per line (scheme + host + optional port, no trailing slash). Only browser tabs from these origins can ask the companion to open files.</div>
<label for="openFileCommand">Open-file command</label>
<div class="input-row">
<input id="openFileCommand" name="openFileCommand" type="text" value="{{.OpenFileCommand}}" placeholder="{{.DefaultOpenFileCommand}}">
<button type="button" class="btn-small" data-reset="openFileCommand" data-default="{{.DefaultOpenFileCommand}}">RESET</button>
</div>
<div class="help">Run when the wiki asks to open a file. Use <code>{{`{path}`}}</code> for the resolved file path. Leave blank to use the default. Default: <code>{{.DefaultOpenFileCommand}}</code></div>
<label for="openFolderCommand">Open-folder command</label>
<div class="input-row">
<input id="openFolderCommand" name="openFolderCommand" type="text" value="{{.OpenFolderCommand}}" placeholder="{{.DefaultOpenFolderCommand}}">
<button type="button" class="btn-small" data-reset="openFolderCommand" data-default="{{.DefaultOpenFolderCommand}}">RESET</button>
</div>
<div class="help">Run when the wiki asks to reveal a folder. Default: <code>{{.DefaultOpenFolderCommand}}</code></div>
<button type="submit">SAVE</button>
</form>
<script>
document.querySelectorAll('button[data-reset]').forEach(function (btn) {
btn.addEventListener('click', function () {
var input = document.getElementById(btn.dataset.reset);
if (input) input.value = btn.dataset.default;
});
});
</script>
<div class="meta">
<div>Config file: <code>{{.ConfigPath}}</code></div>
<div>Log file: <code>{{.LogPath}}</code></div>
<div>Port is set in the config file only; restart after editing.</div>
</div>
<div class="log">
<h2>Log (last 50 lines)</h2>
{{if .LogError}}
<div class="muted">Could not read log: {{.LogError}}</div>
{{else if .LogTail}}
<pre>{{.LogTail}}</pre>
{{else}}
<div class="muted">Log is empty.</div>
{{end}}
</div>
</main>
</body>
</html>
+65
View File
@@ -0,0 +1,65 @@
package main
import (
"io"
"log"
"os"
"path/filepath"
"strings"
)
// setupFileLogging tees the standard logger to a file alongside the config.
// Returns the log file path. Stderr is kept as a secondary sink so dev runs
// (linux, console) still print, while windowsgui builds rely on the file.
func setupFileLogging(cfgDir string) (string, error) {
if err := os.MkdirAll(cfgDir, 0755); err != nil {
return "", err
}
p := filepath.Join(cfgDir, "companion.log")
f, err := os.OpenFile(p, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
return "", err
}
log.SetOutput(io.MultiWriter(f, os.Stderr))
return p, nil
}
// tailLog returns the last n lines of the log file. Reads only the trailing
// chunk of the file so the cost is bounded regardless of log size.
func tailLog(p string, n int) ([]string, error) {
const tailBytes = 32 * 1024
f, err := os.Open(p)
if err != nil {
return nil, err
}
defer f.Close()
info, err := f.Stat()
if err != nil {
return nil, err
}
size := info.Size()
var off int64
if size > tailBytes {
off = size - tailBytes
}
buf := make([]byte, size-off)
if _, err := f.ReadAt(buf, off); err != nil && err != io.EOF {
return nil, err
}
s := string(buf)
// Drop the (likely partial) first line when we didn't start at 0.
if off > 0 {
if i := strings.IndexByte(s, '\n'); i >= 0 {
s = s[i+1:]
}
}
s = strings.TrimRight(s, "\n")
if s == "" {
return nil, nil
}
lines := strings.Split(s, "\n")
if len(lines) > n {
lines = lines[len(lines)-n:]
}
return lines, nil
}
+17 -5
View File
@@ -3,21 +3,33 @@ package main
import (
"flag"
"log"
"path/filepath"
)
const version = "0.1.0"
const version = "1"
func main() {
flag.Parse()
cfg, cfgPath, err := loadOrInitConfig()
cfgPath, err := configPath()
if err != nil {
log.Fatalf("config path: %v", err)
}
logPath, err := setupFileLogging(filepath.Dir(cfgPath))
if err != nil {
log.Fatalf("log setup: %v", err)
}
log.Printf("datascape-companion %s", version)
log.Printf("log file: %s", logPath)
log.Printf("config file: %s", cfgPath)
cfg, _, err := loadOrInitConfig()
if err != nil {
log.Fatalf("config: %v", err)
}
log.Printf("datascape-companion %s", version)
log.Printf("config file: %s", cfgPath)
srv := newServer(cfg, cfgPath)
srv := newServer(cfg, cfgPath, logPath)
log.Printf("listening on http://127.0.0.1:%d", cfg.Port)
log.Printf("settings page: http://127.0.0.1:%d/config", cfg.Port)
if err := srv.run(); err != nil {
-10
View File
@@ -1,10 +0,0 @@
//go:build linux
package main
import "os/exec"
// openOSPath delegates to xdg-open for both files and folders.
func openOSPath(p string, isFolder bool) error {
return exec.Command("xdg-open", p).Start()
}
-15
View File
@@ -1,15 +0,0 @@
//go:build windows
package main
import "os/exec"
// openOSPath asks Windows to open a file in the default app, or a folder in
// Explorer. `cmd /c start` handles default-handler dispatch for files; the
// empty "" argument is the window title required by start.
func openOSPath(p string, isFolder bool) error {
if isFolder {
return exec.Command("explorer.exe", p).Start()
}
return exec.Command("cmd", "/c", "start", "", p).Start()
}
+46 -3
View File
@@ -21,11 +21,12 @@ type server struct {
mu sync.Mutex
cfg *config
cfgPath string
logPath string
port int
}
func newServer(cfg *config, cfgPath string) *server {
return &server{cfg: cfg, cfgPath: cfgPath, port: cfg.Port}
func newServer(cfg *config, cfgPath, logPath string) *server {
return &server{cfg: cfg, cfgPath: cfgPath, logPath: logPath, port: cfg.Port}
}
func (s *server) snapshot() config {
@@ -131,7 +132,14 @@ func (s *server) handleOpen(w http.ResponseWriter, r *http.Request, isFolder boo
writeJSONError(w, http.StatusBadRequest, "path is a directory")
return
}
if err := openOSPath(resolved, isFolder); err != nil {
defs := defaultCommands()
var template string
if isFolder {
template = resolveOpenCommand(cfg.OpenFolderCommand, defs.OpenFolder)
} else {
template = resolveOpenCommand(cfg.OpenFileCommand, defs.OpenFile)
}
if err := runOpenCommand(template, resolved); err != nil {
writeJSONError(w, http.StatusInternalServerError, err.Error())
return
}
@@ -155,19 +163,40 @@ type configPageData struct {
AllowedOrigins string
Port int
ConfigPath string
LogPath string
LogTail string
LogError string
Version string
Notice string
OpenFileCommand string
OpenFolderCommand string
DefaultOpenFileCommand string
DefaultOpenFolderCommand string
}
func (s *server) renderConfig(w http.ResponseWriter, notice string) {
cfg := s.snapshot()
defs := defaultCommands()
data := configPageData{
WikiRoot: cfg.WikiRoot,
AllowedOrigins: strings.Join(cfg.AllowedOrigins, "\n"),
Port: cfg.Port,
ConfigPath: s.cfgPath,
LogPath: s.logPath,
Version: version,
Notice: notice,
OpenFileCommand: cfg.OpenFileCommand,
OpenFolderCommand: cfg.OpenFolderCommand,
DefaultOpenFileCommand: defs.OpenFile,
DefaultOpenFolderCommand: defs.OpenFolder,
}
if s.logPath != "" {
lines, err := tailLog(s.logPath, 50)
if err != nil {
data.LogError = err.Error()
} else {
data.LogTail = strings.Join(lines, "\n")
}
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := configTmpl.Execute(w, data); err != nil {
@@ -200,10 +229,24 @@ func (s *server) saveConfig(w http.ResponseWriter, r *http.Request) {
origins = []string{}
}
openFileCmd := strings.TrimSpace(r.FormValue("openFileCommand"))
openFolderCmd := strings.TrimSpace(r.FormValue("openFolderCommand"))
defs := defaultCommands()
// Persist as blank when the user submits the default verbatim, so the
// config file stays clean and future default changes propagate.
if openFileCmd == defs.OpenFile {
openFileCmd = ""
}
if openFolderCmd == defs.OpenFolder {
openFolderCmd = ""
}
s.mu.Lock()
newCfg := *s.cfg
newCfg.WikiRoot = wikiRoot
newCfg.AllowedOrigins = origins
newCfg.OpenFileCommand = openFileCmd
newCfg.OpenFolderCommand = openFolderCmd
if err := writeConfigFile(s.cfgPath, &newCfg); err != nil {
s.mu.Unlock()
http.Error(w, "save failed: "+err.Error(), http.StatusInternalServerError)