Allow customizing companion commands
This commit is contained in:
@@ -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
@@ -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() {
|
||||
|
||||
@@ -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}}">
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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 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>
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user