Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 22196ab9ba | |||
| 5e544eee3d | |||
| 9521cb986b | |||
| 30e01765ae |
7
.gitignore
vendored
7
.gitignore
vendored
@@ -55,3 +55,10 @@ node_modules/
|
|||||||
|
|
||||||
# Backups of .env made when editing camera credentials.
|
# Backups of .env made when editing camera credentials.
|
||||||
/.env.bak-*
|
/.env.bak-*
|
||||||
|
|
||||||
|
# Generated by the wails CLI on every dev run and build, not source.
|
||||||
|
# NOT /desktop/build/ as a whole: appicon.png, darwin/ and windows/ under it
|
||||||
|
# are the Wails project scaffolding (icon, Info.plist, manifest) that a
|
||||||
|
# reproducible Windows build needs. Only the compiled output is ignored.
|
||||||
|
/desktop/frontend/wailsjs/
|
||||||
|
/desktop/frontend/package.json.md5
|
||||||
|
|||||||
331
agent/cmd/behavision-setup/main.go
Normal file
331
agent/cmd/behavision-setup/main.go
Normal file
@@ -0,0 +1,331 @@
|
|||||||
|
// Command behavision-setup prepares a shop PC to run the recognition engine.
|
||||||
|
//
|
||||||
|
// It exists because the engine is Python and the rest of the product is Go.
|
||||||
|
// The Go halves cross-compile to Windows from any machine; the engine, frozen
|
||||||
|
// with PyInstaller, does not - PyInstaller bundles the interpreter and native
|
||||||
|
// wheels of the machine it runs on, so a frozen engine can only be built on
|
||||||
|
// Windows. That single fact was the whole reason a release could not be cut.
|
||||||
|
//
|
||||||
|
// So this installs the engine from source instead of shipping it frozen: find
|
||||||
|
// a Python, build a private virtual environment beside the database, install
|
||||||
|
// the engine into it, fetch the models, and record how to start it. Everything
|
||||||
|
// in the release can then be built anywhere.
|
||||||
|
//
|
||||||
|
// The trade, stated plainly because whoever runs this is standing in a shop:
|
||||||
|
// it needs Python and a working internet connection at install time, and it
|
||||||
|
// takes minutes rather than seconds. A frozen build needs neither. What it
|
||||||
|
// buys is a release that exists.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/loyaly/behavision-agent/pkg/config"
|
||||||
|
"github.com/loyaly/behavision-agent/pkg/paths"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The engine needs 3.10; nothing here works below it and the failure would
|
||||||
|
// otherwise arrive as a syntax error deep inside a dependency.
|
||||||
|
const minMinor = 10
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if err := run(); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "\n Setup did not finish: %v\n\n", err)
|
||||||
|
pause()
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
pause()
|
||||||
|
}
|
||||||
|
|
||||||
|
func run() error {
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Println(" Behavision setup")
|
||||||
|
fmt.Println(" ----------------")
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
|
state := paths.StateRoot()
|
||||||
|
src, err := engineSource()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Printf(" engine source %s\n", src)
|
||||||
|
fmt.Printf(" install into %s\n", state)
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
|
if err := paths.EnsureState(); err != nil {
|
||||||
|
return fmt.Errorf("could not create %s: %w", state, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
py, ver, err := findPython()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
step("Python", fmt.Sprintf("%s (%s)", ver, py))
|
||||||
|
|
||||||
|
venv := filepath.Join(state, "runtime")
|
||||||
|
if err := makeVenv(py, venv); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
vpy := venvPython(venv)
|
||||||
|
step("Virtual environment", venv)
|
||||||
|
|
||||||
|
// --upgrade so re-running after a new release replaces the engine rather
|
||||||
|
// than leaving the old one in place and reporting success.
|
||||||
|
if err := pipInstall(vpy, src); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
step("Engine and dependencies", "installed")
|
||||||
|
|
||||||
|
if err := runEngine(vpy, "setup-models"); err != nil {
|
||||||
|
return fmt.Errorf("downloading the recognition models: %w", err)
|
||||||
|
}
|
||||||
|
step("Recognition models", "downloaded")
|
||||||
|
|
||||||
|
if err := writeConfig(vpy); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
step("Startup settings", filepath.Join(state, "agent.json"))
|
||||||
|
|
||||||
|
// Proving it starts is the point. An installer that reports success and
|
||||||
|
// leaves a shop with an engine that will not run has done worse than
|
||||||
|
// failing: the failure surfaces later, to someone who did not install it.
|
||||||
|
if err := smokeTest(vpy); err != nil {
|
||||||
|
return fmt.Errorf("the engine installed but would not start: %w", err)
|
||||||
|
}
|
||||||
|
step("Engine starts and answers", "verified")
|
||||||
|
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Println(" Done. Start Behavision from the Start menu or the desktop icon.")
|
||||||
|
fmt.Println(" It appears in the system tray; right-click there to stop it.")
|
||||||
|
fmt.Println()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func step(label, detail string) {
|
||||||
|
fmt.Printf(" [ok] %-24s %s\n", label, detail)
|
||||||
|
}
|
||||||
|
|
||||||
|
// engineSource finds the Python source shipped beside this executable. Beside,
|
||||||
|
// not downloaded: the engine and the app must be the same release, and a
|
||||||
|
// version skew between them is the class of bug nobody can reproduce.
|
||||||
|
func engineSource() (string, error) {
|
||||||
|
candidates := []string{
|
||||||
|
filepath.Join(paths.InstallRoot(), "engine-src"),
|
||||||
|
filepath.Join(paths.InstallRoot(), "..", "engine-src"),
|
||||||
|
}
|
||||||
|
if wd, err := os.Getwd(); err == nil {
|
||||||
|
candidates = append(candidates, filepath.Join(wd, "engine-src"), wd)
|
||||||
|
}
|
||||||
|
for _, c := range candidates {
|
||||||
|
if _, err := os.Stat(filepath.Join(c, "pyproject.toml")); err == nil {
|
||||||
|
abs, _ := filepath.Abs(c)
|
||||||
|
return abs, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", errors.New("could not find the engine source (expected an " +
|
||||||
|
"engine-src folder with pyproject.toml beside this program). " +
|
||||||
|
"Unzip the whole release together rather than moving this file out of it")
|
||||||
|
}
|
||||||
|
|
||||||
|
// findPython returns the first interpreter that is new enough.
|
||||||
|
//
|
||||||
|
// `py -3` first on Windows: the launcher is what the official installer puts
|
||||||
|
// on PATH, and `python` there is often the Microsoft Store stub that prints an
|
||||||
|
// advert and exits 9009 instead of running anything.
|
||||||
|
func findPython() (string, string, error) {
|
||||||
|
type cand struct {
|
||||||
|
exe string
|
||||||
|
args []string
|
||||||
|
}
|
||||||
|
var cands []cand
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
cands = append(cands, cand{"py", []string{"-3"}})
|
||||||
|
}
|
||||||
|
cands = append(cands, cand{"python3", nil}, cand{"python", nil})
|
||||||
|
|
||||||
|
var tried []string
|
||||||
|
for _, c := range cands {
|
||||||
|
exe, err := exec.LookPath(c.exe)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
args := append(append([]string{}, c.args...), "-c",
|
||||||
|
"import sys;print('%d.%d'%sys.version_info[:2])")
|
||||||
|
out, err := exec.Command(exe, args...).Output()
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ver := strings.TrimSpace(string(out))
|
||||||
|
tried = append(tried, c.exe+" "+ver)
|
||||||
|
if major, minor, ok := parseVer(ver); ok && (major > 3 || (major == 3 && minor >= minMinor)) {
|
||||||
|
full := exe
|
||||||
|
if len(c.args) > 0 {
|
||||||
|
full = exe + " " + strings.Join(c.args, " ")
|
||||||
|
}
|
||||||
|
return full, "Python " + ver, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := "no Python 3.10 or newer was found on this PC.\n\n" +
|
||||||
|
" Install it from https://www.python.org/downloads/windows/\n" +
|
||||||
|
" and tick \"Add python.exe to PATH\" on the first screen,\n" +
|
||||||
|
" then run this again."
|
||||||
|
if len(tried) > 0 {
|
||||||
|
msg += "\n\n Found, but too old: " + strings.Join(tried, ", ")
|
||||||
|
}
|
||||||
|
return "", "", errors.New(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseVer(s string) (int, int, bool) {
|
||||||
|
parts := strings.Split(s, ".")
|
||||||
|
if len(parts) < 2 {
|
||||||
|
return 0, 0, false
|
||||||
|
}
|
||||||
|
major, err1 := strconv.Atoi(parts[0])
|
||||||
|
minor, err2 := strconv.Atoi(parts[1])
|
||||||
|
return major, minor, err1 == nil && err2 == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// splitLauncher turns `py -3` back into a command and its arguments.
|
||||||
|
func splitLauncher(s string) (string, []string) {
|
||||||
|
f := strings.Fields(s)
|
||||||
|
if len(f) == 0 {
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
return f[0], f[1:]
|
||||||
|
}
|
||||||
|
|
||||||
|
func venvPython(venv string) string {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
return filepath.Join(venv, "Scripts", "python.exe")
|
||||||
|
}
|
||||||
|
return filepath.Join(venv, "bin", "python")
|
||||||
|
}
|
||||||
|
|
||||||
|
// makeVenv builds the engine's own interpreter under the writable state root.
|
||||||
|
//
|
||||||
|
// A virtual environment rather than the system Python: a shop PC may have
|
||||||
|
// Python there for something else, and pinning numpy below 2.0 - which the
|
||||||
|
// engine requires - inside a shared interpreter is how you break the other
|
||||||
|
// thing months later, silently.
|
||||||
|
func makeVenv(py, venv string) error {
|
||||||
|
if _, err := os.Stat(venvPython(venv)); err == nil {
|
||||||
|
return nil // already built; pip below brings it up to date
|
||||||
|
}
|
||||||
|
exe, args := splitLauncher(py)
|
||||||
|
args = append(args, "-m", "venv", venv)
|
||||||
|
return stream(exec.Command(exe, args...), "creating the virtual environment")
|
||||||
|
}
|
||||||
|
|
||||||
|
func pipInstall(vpy, src string) error {
|
||||||
|
fmt.Println(" Installing the engine and its libraries. This downloads a few")
|
||||||
|
fmt.Println(" hundred megabytes and takes a while on a slow connection.")
|
||||||
|
fmt.Println()
|
||||||
|
if err := stream(exec.Command(vpy, "-m", "pip", "install", "--upgrade",
|
||||||
|
"pip", "setuptools", "wheel"), "updating pip"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return stream(exec.Command(vpy, "-m", "pip", "install", "--upgrade", src),
|
||||||
|
"installing the engine")
|
||||||
|
}
|
||||||
|
|
||||||
|
func runEngine(vpy string, args ...string) error {
|
||||||
|
full := append([]string{"-m", "behavision"}, args...)
|
||||||
|
return stream(exec.Command(vpy, full...), "running the engine")
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeConfig records how to start the engine, in the same file and through
|
||||||
|
// the same type the app reads, so the two cannot disagree about it.
|
||||||
|
func writeConfig(vpy string) error {
|
||||||
|
path := paths.AgentConfig()
|
||||||
|
cfg, err := config.Load(path)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("reading %s: %w", path, err)
|
||||||
|
}
|
||||||
|
// An absolute path: the app resolves a relative EngineExe against its own
|
||||||
|
// install root under Program Files, and the interpreter is not there.
|
||||||
|
cfg.EngineExe = vpy
|
||||||
|
cfg.EngineArgs = []string{"-m", "behavision", "run"}
|
||||||
|
if cfg.APIBase == "" {
|
||||||
|
cfg.APIBase = "http://127.0.0.1:8010"
|
||||||
|
}
|
||||||
|
return cfg.Save(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// smokeTest starts the engine exactly as the app will and waits for its API to
|
||||||
|
// answer. Any reply counts, including 401: the engine invents its own
|
||||||
|
// credential when none is configured, and a refusal proves it is serving.
|
||||||
|
func smokeTest(vpy string) error {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
cmd := exec.CommandContext(ctx, vpy, "-m", "behavision", "run")
|
||||||
|
var log strings.Builder
|
||||||
|
cmd.Stdout, cmd.Stderr = &log, &log
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
_ = cmd.Process.Kill()
|
||||||
|
_, _ = cmd.Process.Wait()
|
||||||
|
}()
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: 3 * time.Second}
|
||||||
|
deadline := time.Now().Add(75 * time.Second)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
resp, err := client.Get("http://127.0.0.1:8010/api/health")
|
||||||
|
if err == nil {
|
||||||
|
_, _ = io.Copy(io.Discard, resp.Body)
|
||||||
|
resp.Body.Close()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if cmd.ProcessState != nil && cmd.ProcessState.Exited() {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
time.Sleep(2 * time.Second)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("it did not answer within 75 seconds.\n\n%s",
|
||||||
|
tail(log.String(), 15))
|
||||||
|
}
|
||||||
|
|
||||||
|
func tail(s string, n int) string {
|
||||||
|
lines := strings.Split(strings.TrimRight(s, "\n"), "\n")
|
||||||
|
if len(lines) > n {
|
||||||
|
lines = lines[len(lines)-n:]
|
||||||
|
}
|
||||||
|
return " " + strings.Join(lines, "\n ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// stream runs a command and shows its output. Shown, not swallowed: pip failing
|
||||||
|
// on a missing build tool prints exactly what is wrong, and hiding that leaves
|
||||||
|
// the operator with "setup failed" and nothing to act on.
|
||||||
|
func stream(cmd *exec.Cmd, what string) error {
|
||||||
|
cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
return fmt.Errorf("%s failed: %w", what, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// pause keeps the window open. Double-clicked from Explorer, a console program
|
||||||
|
// that finishes closes instantly and the operator sees nothing at all -
|
||||||
|
// success and failure look identical.
|
||||||
|
func pause() {
|
||||||
|
if runtime.GOOS != "windows" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Print(" Press Enter to close. ")
|
||||||
|
_, _ = bufio.NewReader(os.Stdin).ReadString('\n')
|
||||||
|
}
|
||||||
@@ -43,6 +43,9 @@ type App struct {
|
|||||||
broker *agentmqtt.Client
|
broker *agentmqtt.Client
|
||||||
stopBridge func()
|
stopBridge func()
|
||||||
hookURL string
|
hookURL string
|
||||||
|
// Relays camera feeds to the webview so the engine's credential never has
|
||||||
|
// to travel in an <img> src, which a Chromium webview would strip anyway.
|
||||||
|
proxy *streamProxy
|
||||||
// Set once the operator logs in. Until then the UI shows the login sheet
|
// Set once the operator logs in. Until then the UI shows the login sheet
|
||||||
// and nothing else is reachable.
|
// and nothing else is reachable.
|
||||||
onSessionChange func(bool)
|
onSessionChange func(bool)
|
||||||
@@ -63,6 +66,7 @@ func NewApp() *App {
|
|||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
cloud: cloud.New(envOr("BEHAVISION_CLOUD", "https://mcp.loyaly.ai")),
|
cloud: cloud.New(envOr("BEHAVISION_CLOUD", "https://mcp.loyaly.ai")),
|
||||||
local: local.New(base, cfg.APIUser, cfg.APIPassword),
|
local: local.New(base, cfg.APIUser, cfg.APIPassword),
|
||||||
|
proxy: newStreamProxy(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,6 +74,14 @@ func (a *App) startup(ctx context.Context) {
|
|||||||
a.ctx = ctx
|
a.ctx = ctx
|
||||||
_ = agentpaths.EnsureState()
|
_ = agentpaths.EnsureState()
|
||||||
|
|
||||||
|
// Before any screen asks for a camera URL. A failure here is logged and
|
||||||
|
// not fatal: the rest of the app - people, cameras, the engine controls -
|
||||||
|
// works without a picture, and refusing to start over a broken tile would
|
||||||
|
// take a working shop offline.
|
||||||
|
if err := a.proxy.start(a.local.Base, a.local.User, a.local.Password); err != nil {
|
||||||
|
log.Printf("camera relay unavailable, tiles will not load: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
// A saved session means a shop PC that rebooted overnight comes back
|
// A saved session means a shop PC that rebooted overnight comes back
|
||||||
// working instead of waiting for someone to log in.
|
// working instead of waiting for someone to log in.
|
||||||
if a.cfg.SessionToken != "" {
|
if a.cfg.SessionToken != "" {
|
||||||
@@ -273,8 +285,8 @@ type PipelineStatus struct {
|
|||||||
// Standalone separates "nothing is being sent because this PC is set up on
|
// Standalone separates "nothing is being sent because this PC is set up on
|
||||||
// its own" from "nothing is being sent and something is wrong". They look
|
// its own" from "nothing is being sent and something is wrong". They look
|
||||||
// identical from the counters alone, and only one of them is a fault.
|
// identical from the counters alone, and only one of them is a fault.
|
||||||
Standalone bool `json:"standalone"`
|
Standalone bool `json:"standalone"`
|
||||||
BrokerUp bool `json:"broker_up"`
|
BrokerUp bool `json:"broker_up"`
|
||||||
Accepted uint64 `json:"accepted"`
|
Accepted uint64 `json:"accepted"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -549,15 +561,25 @@ func (a *App) PlacementResult(id string) (map[string]any, error) {
|
|||||||
return a.local.PlacementResult(ctx, id)
|
return a.local.PlacementResult(ctx, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
// StreamURL is the MJPEG endpoint for a camera, with credentials inline so an
|
// StreamURL is the MJPEG endpoint for a camera tile.
|
||||||
// <img> tag can load it. Loopback only - it never leaves this machine.
|
//
|
||||||
|
// It points at this app's own loopback relay, not at the engine directly. The
|
||||||
|
// previous version put the engine's Basic credentials inline in the URL, with
|
||||||
|
// a comment saying they were there "so an <img> tag can load it" - which a
|
||||||
|
// browser will not do. Chromium strips credentials from subresource URLs, and
|
||||||
|
// WebView2 is Chromium, so every camera tile on a shop PC was a broken image.
|
||||||
|
// See stream_proxy.go for the measurement.
|
||||||
|
//
|
||||||
|
// The relay is also why no password appears in the page any more. If it is not
|
||||||
|
// running the fallback is the bare engine URL with no credential: correct for
|
||||||
|
// an engine configured without auth, and for one with auth a tile that fails
|
||||||
|
// to load rather than a password sitting in the DOM.
|
||||||
func (a *App) StreamURL(cameraID string) string {
|
func (a *App) StreamURL(cameraID string) string {
|
||||||
base := strings.TrimPrefix(strings.TrimPrefix(a.local.Base, "http://"), "https://")
|
if u := a.proxy.urlFor(cameraID, "stream.mjpeg"); u != "" {
|
||||||
if a.local.User == "" {
|
return u
|
||||||
return fmt.Sprintf("http://%s/api/cameras/%s/stream.mjpeg", base, cameraID)
|
|
||||||
}
|
}
|
||||||
return fmt.Sprintf("http://%s:%s@%s/api/cameras/%s/stream.mjpeg",
|
base := strings.TrimPrefix(strings.TrimPrefix(a.local.Base, "http://"), "https://")
|
||||||
a.local.User, a.local.Password, base, cameraID)
|
return fmt.Sprintf("http://%s/api/cameras/%s/stream.mjpeg", base, cameraID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------------- live --
|
// ------------------------------------------------------------------- live --
|
||||||
|
|||||||
@@ -14,16 +14,34 @@ require (
|
|||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/bep/debounce v1.2.1 // indirect
|
||||||
github.com/eclipse/paho.mqtt.golang v1.4.3 // indirect
|
github.com/eclipse/paho.mqtt.golang v1.4.3 // indirect
|
||||||
|
github.com/go-ole/go-ole v1.2.6 // indirect
|
||||||
github.com/godbus/dbus/v5 v5.1.0 // indirect
|
github.com/godbus/dbus/v5 v5.1.0 // indirect
|
||||||
|
github.com/google/uuid v1.3.0 // indirect
|
||||||
github.com/gorilla/websocket v1.5.0 // indirect
|
github.com/gorilla/websocket v1.5.0 // indirect
|
||||||
|
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect
|
||||||
|
github.com/labstack/echo/v4 v4.10.2 // indirect
|
||||||
|
github.com/labstack/gommon v0.4.0 // indirect
|
||||||
github.com/leaanthony/go-ansi-parser v1.6.0 // indirect
|
github.com/leaanthony/go-ansi-parser v1.6.0 // indirect
|
||||||
|
github.com/leaanthony/gosod v1.0.3 // indirect
|
||||||
github.com/leaanthony/slicer v1.6.0 // indirect
|
github.com/leaanthony/slicer v1.6.0 // indirect
|
||||||
github.com/leaanthony/u v1.1.0 // indirect
|
github.com/leaanthony/u v1.1.0 // indirect
|
||||||
|
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.19 // indirect
|
||||||
|
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 // indirect
|
||||||
github.com/pkg/errors v0.9.1 // indirect
|
github.com/pkg/errors v0.9.1 // indirect
|
||||||
github.com/rivo/uniseg v0.4.4 // indirect
|
github.com/rivo/uniseg v0.4.4 // indirect
|
||||||
|
github.com/samber/lo v1.38.1 // indirect
|
||||||
|
github.com/tkrajina/go-reflector v0.5.6 // indirect
|
||||||
|
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||||
|
github.com/valyala/fasttemplate v1.2.2 // indirect
|
||||||
github.com/wailsapp/go-webview2 v1.0.16 // indirect
|
github.com/wailsapp/go-webview2 v1.0.16 // indirect
|
||||||
|
github.com/wailsapp/mimetype v1.4.1 // indirect
|
||||||
|
golang.org/x/crypto v0.23.0 // indirect
|
||||||
|
golang.org/x/exp v0.0.0-20230522175609-2e198f4a06a1 // indirect
|
||||||
golang.org/x/net v0.25.0 // indirect
|
golang.org/x/net v0.25.0 // indirect
|
||||||
golang.org/x/sync v0.1.0 // indirect
|
golang.org/x/sync v0.1.0 // indirect
|
||||||
golang.org/x/sys v0.20.0 // indirect
|
golang.org/x/sys v0.20.0 // indirect
|
||||||
|
golang.org/x/text v0.15.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ fyne.io/systray v1.12.2/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs=
|
|||||||
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
|
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
|
||||||
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
|
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/eclipse/paho.mqtt.golang v1.4.3 h1:2kwcUGn8seMUfWndX0hGbvH8r7crgcJguQNCyp70xik=
|
github.com/eclipse/paho.mqtt.golang v1.4.3 h1:2kwcUGn8seMUfWndX0hGbvH8r7crgcJguQNCyp70xik=
|
||||||
github.com/eclipse/paho.mqtt.golang v1.4.3/go.mod h1:CSYvoAlsMkhYOXh/oKyxa8EcBci6dVkLCbo5tTC1RIE=
|
github.com/eclipse/paho.mqtt.golang v1.4.3/go.mod h1:CSYvoAlsMkhYOXh/oKyxa8EcBci6dVkLCbo5tTC1RIE=
|
||||||
@@ -20,6 +21,7 @@ github.com/labstack/echo/v4 v4.10.2 h1:n1jAhnq/elIFTHr1EYpiYtyKgx4RW9ccVgkqByZaN
|
|||||||
github.com/labstack/echo/v4 v4.10.2/go.mod h1:OEyqf2//K1DFdE57vw2DRgWY0M7s65IVQO2FzvI4J5k=
|
github.com/labstack/echo/v4 v4.10.2/go.mod h1:OEyqf2//K1DFdE57vw2DRgWY0M7s65IVQO2FzvI4J5k=
|
||||||
github.com/labstack/gommon v0.4.0 h1:y7cvthEAEbU0yHOf4axH8ZG2NH8knB9iNSoTO8dyIk8=
|
github.com/labstack/gommon v0.4.0 h1:y7cvthEAEbU0yHOf4axH8ZG2NH8knB9iNSoTO8dyIk8=
|
||||||
github.com/labstack/gommon v0.4.0/go.mod h1:uW6kP17uPlLJsD3ijUYn3/M5bAxtlZhMI6m3MFxTMTM=
|
github.com/labstack/gommon v0.4.0/go.mod h1:uW6kP17uPlLJsD3ijUYn3/M5bAxtlZhMI6m3MFxTMTM=
|
||||||
|
github.com/leaanthony/debme v1.2.1 h1:9Tgwf+kjcrbMQ4WnPcEIUcQuIZYqdWftzZkBr+i/oOc=
|
||||||
github.com/leaanthony/debme v1.2.1/go.mod h1:3V+sCm5tYAgQymvSOfYQ5Xx2JCr+OXiD9Jkw3otUjiA=
|
github.com/leaanthony/debme v1.2.1/go.mod h1:3V+sCm5tYAgQymvSOfYQ5Xx2JCr+OXiD9Jkw3otUjiA=
|
||||||
github.com/leaanthony/go-ansi-parser v1.6.0 h1:T8TuMhFB6TUMIUm0oRrSbgJudTFw9csT3ZK09w0t4Pg=
|
github.com/leaanthony/go-ansi-parser v1.6.0 h1:T8TuMhFB6TUMIUm0oRrSbgJudTFw9csT3ZK09w0t4Pg=
|
||||||
github.com/leaanthony/go-ansi-parser v1.6.0/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU=
|
github.com/leaanthony/go-ansi-parser v1.6.0/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU=
|
||||||
@@ -30,6 +32,7 @@ github.com/leaanthony/slicer v1.6.0 h1:1RFP5uiPJvT93TAHi+ipd3NACobkW53yUiBqZheE/
|
|||||||
github.com/leaanthony/slicer v1.6.0/go.mod h1:o/Iz29g7LN0GqH3aMjWAe90381nyZlDNquK+mtH2Fj8=
|
github.com/leaanthony/slicer v1.6.0/go.mod h1:o/Iz29g7LN0GqH3aMjWAe90381nyZlDNquK+mtH2Fj8=
|
||||||
github.com/leaanthony/u v1.1.0 h1:2n0d2BwPVXSUq5yhe8lJPHdxevE2qK5G99PMStMZMaI=
|
github.com/leaanthony/u v1.1.0 h1:2n0d2BwPVXSUq5yhe8lJPHdxevE2qK5G99PMStMZMaI=
|
||||||
github.com/leaanthony/u v1.1.0/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI=
|
github.com/leaanthony/u v1.1.0/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI=
|
||||||
|
github.com/matryer/is v1.4.0 h1:sosSmIWwkYITGrxZ25ULNDeKiMNzFSr4V/eqBQP0PeE=
|
||||||
github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
|
github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
|
||||||
github.com/mattn/go-colorable v0.1.11/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
|
github.com/mattn/go-colorable v0.1.11/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
|
||||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||||
@@ -42,6 +45,7 @@ github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 h1:KoWmjvw+nsYOo29YJK9
|
|||||||
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI=
|
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI=
|
||||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||||
github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis=
|
github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis=
|
||||||
@@ -50,6 +54,8 @@ github.com/samber/lo v1.38.1 h1:j2XEAqXKb09Am4ebOg31SpvzUTTs6EN3VfgeLUhPdXM=
|
|||||||
github.com/samber/lo v1.38.1/go.mod h1:+m/ZKRl6ClXCE2Lgf3MsQlWfh4bn1bz6CXEOxnEXnEA=
|
github.com/samber/lo v1.38.1/go.mod h1:+m/ZKRl6ClXCE2Lgf3MsQlWfh4bn1bz6CXEOxnEXnEA=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
|
||||||
|
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||||
github.com/tkrajina/go-reflector v0.5.6 h1:hKQ0gyocG7vgMD2M3dRlYN6WBBOmdoOzJ6njQSepKdE=
|
github.com/tkrajina/go-reflector v0.5.6 h1:hKQ0gyocG7vgMD2M3dRlYN6WBBOmdoOzJ6njQSepKdE=
|
||||||
github.com/tkrajina/go-reflector v0.5.6/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4=
|
github.com/tkrajina/go-reflector v0.5.6/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4=
|
||||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||||
@@ -92,3 +98,5 @@ golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGm
|
|||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ func main() {
|
|||||||
},
|
},
|
||||||
OnShutdown: func(ctx context.Context) {
|
OnShutdown: func(ctx context.Context) {
|
||||||
tray.stop()
|
tray.stop()
|
||||||
|
app.proxy.stop()
|
||||||
app.StopEngine()
|
app.StopEngine()
|
||||||
},
|
},
|
||||||
Bind: []any{app},
|
Bind: []any{app},
|
||||||
|
|||||||
249
desktop/stream_proxy.go
Normal file
249
desktop/stream_proxy.go
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// streamProxy serves the engine's camera feeds to this app's own webview
|
||||||
|
// without putting a credential in the page.
|
||||||
|
//
|
||||||
|
// What this replaces: StreamURL used to build
|
||||||
|
// http://user:pass@127.0.0.1:8010/api/cameras/<id>/stream.mjpeg and hand it
|
||||||
|
// to an <img>, with a comment saying the credentials were inline "so an <img>
|
||||||
|
// tag can load it". It cannot. Chromium strips credentials from subresource
|
||||||
|
// URLs and has since M59, and WebView2 is Chromium - so on the one platform
|
||||||
|
// this product ships to, every camera tile on the shop floor renders as a
|
||||||
|
// broken image. Measured against the same running engine: the app's Go-side
|
||||||
|
// calls returned stats and people while an <img> on the very same URL failed,
|
||||||
|
// and curl proved the URL itself answered 200. The engine was never the
|
||||||
|
// problem; the browser was throwing the password away before it asked.
|
||||||
|
//
|
||||||
|
// So the password stays on this side of the process boundary. The webview
|
||||||
|
// asks this loopback listener, the listener attaches Basic auth and relays
|
||||||
|
// the engine's bytes back unchanged. It is the same reasoning the head-office
|
||||||
|
// web app already follows in Shot.jsx, where an <img> equally cannot carry a
|
||||||
|
// session and the bytes are fetched and handed over as an object URL.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/subtle"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A camera id reaches this from the engine and from a person typing into the
|
||||||
|
// Add Camera form. Validated rather than interpolated: without this a `..`
|
||||||
|
// would climb out of the two paths below and turn a camera relay into a proxy
|
||||||
|
// for any engine endpoint, with the credential helpfully attached.
|
||||||
|
var safeCameraIDChars = regexp.MustCompile(`^[A-Za-z0-9_.-]{1,64}$`)
|
||||||
|
|
||||||
|
// safeCameraID is the character check AND the two names that pass it and still
|
||||||
|
// mean something to a path resolver.
|
||||||
|
//
|
||||||
|
// The pattern allows `.` because real camera ids contain them - which means it
|
||||||
|
// also allows exactly `.` and `..`, and `/api/cameras/../stream.mjpeg` is not
|
||||||
|
// the endpoint anyone intended. The id can never contain a slash (the path is
|
||||||
|
// split on them before we get here), so these two strings are the entire
|
||||||
|
// remaining traversal surface. Found by the test, not by reading the regex.
|
||||||
|
func safeCameraID(id string) bool {
|
||||||
|
if id == "." || id == ".." {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return safeCameraIDChars.MatchString(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
type streamProxy struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
ln net.Listener
|
||||||
|
srv *http.Server
|
||||||
|
client *http.Client
|
||||||
|
token string
|
||||||
|
target string // engine origin, e.g. http://127.0.0.1:8010
|
||||||
|
user string
|
||||||
|
pass string
|
||||||
|
}
|
||||||
|
|
||||||
|
func newStreamProxy() *streamProxy { return &streamProxy{} }
|
||||||
|
|
||||||
|
// start binds a loopback listener and begins relaying. Calling it again while
|
||||||
|
// running is a no-op, so a restarted engine cannot leave two listeners behind.
|
||||||
|
func (p *streamProxy) start(base, user, pass string) error {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
if p.srv != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.HasPrefix(base, "http://") && !strings.HasPrefix(base, "https://") {
|
||||||
|
base = "http://" + base
|
||||||
|
}
|
||||||
|
if _, err := url.Parse(base); err != nil {
|
||||||
|
return fmt.Errorf("engine base %q: %w", base, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The engine's own credential exists precisely so that the live face feed
|
||||||
|
// is never served open - CLAUDE.md is explicit that an unauthenticated
|
||||||
|
// listener would expose it. An unauthenticated loopback relay would hand
|
||||||
|
// that same feed to any other process on this PC, which on a shop counter
|
||||||
|
// is not a theoretical set. A per-run token, minted here and given only to
|
||||||
|
// this app's own webview, keeps the relay as private as the engine is.
|
||||||
|
raw := make([]byte, 32)
|
||||||
|
if _, err := rand.Read(raw); err != nil {
|
||||||
|
return fmt.Errorf("proxy token: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Port 0: the OS picks a free one. A fixed port would collide with
|
||||||
|
// whatever else a shop PC happens to be running, and the failure would be
|
||||||
|
// "the cameras stopped working" with nothing pointing at the cause.
|
||||||
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("stream proxy listen: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
p.ln = ln
|
||||||
|
p.token = hex.EncodeToString(raw)
|
||||||
|
p.target = strings.TrimRight(base, "/")
|
||||||
|
p.user, p.pass = user, pass
|
||||||
|
// No client timeout: an MJPEG stream is endless by design and any deadline
|
||||||
|
// would cut the picture off mid-shift. The request context ends it when
|
||||||
|
// the webview navigates away or the tile is replaced.
|
||||||
|
p.client = &http.Client{
|
||||||
|
Transport: &http.Transport{
|
||||||
|
DialContext: (&net.Dialer{Timeout: 5 * time.Second}).DialContext,
|
||||||
|
TLSHandshakeTimeout: 5 * time.Second,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
srv := &http.Server{Handler: http.HandlerFunc(p.handle)}
|
||||||
|
p.srv = srv
|
||||||
|
|
||||||
|
// srv and ln are captured, not read off the struct inside the goroutine:
|
||||||
|
// stop() sets both to nil, so a serve loop that reached for them after a
|
||||||
|
// quick start/stop would dereference nil and take the whole app down. The
|
||||||
|
// test that stops the relay found exactly that.
|
||||||
|
go func() { _ = srv.Serve(ln) }()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *streamProxy) stop() {
|
||||||
|
p.mu.Lock()
|
||||||
|
srv, ln := p.srv, p.ln
|
||||||
|
p.srv, p.ln, p.token = nil, nil, ""
|
||||||
|
p.mu.Unlock()
|
||||||
|
if srv != nil {
|
||||||
|
_ = srv.Close()
|
||||||
|
}
|
||||||
|
if ln != nil {
|
||||||
|
_ = ln.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// urlFor returns the loopback URL for one camera resource, or "" when the
|
||||||
|
// proxy is not running so the caller can fall back.
|
||||||
|
func (p *streamProxy) urlFor(cameraID, file string) string {
|
||||||
|
p.mu.RLock()
|
||||||
|
defer p.mu.RUnlock()
|
||||||
|
if p.ln == nil || p.token == "" || !safeCameraID(cameraID) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("http://%s/s/%s/%s/%s",
|
||||||
|
p.ln.Addr().String(), p.token, cameraID, file)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *streamProxy) handle(w http.ResponseWriter, r *http.Request) {
|
||||||
|
p.mu.RLock()
|
||||||
|
token, target, user, pass, client := p.token, p.target, p.user, p.pass, p.client
|
||||||
|
p.mu.RUnlock()
|
||||||
|
if token == "" || client == nil {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// /s/<token>/<camera>/<file>
|
||||||
|
parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/"), "/")
|
||||||
|
if len(parts) != 4 || parts[0] != "s" {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Constant time: the token is the only thing standing between another
|
||||||
|
// local process and a live view of customers' faces.
|
||||||
|
if subtle.ConstantTimeCompare([]byte(parts[1]), []byte(token)) != 1 {
|
||||||
|
// 404 rather than 403. There is nothing here to tell an unwelcome
|
||||||
|
// caller they have found the right door with the wrong key.
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cameraID := parts[2]
|
||||||
|
if !safeCameraID(cameraID) {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// An allow-list, not a prefix match. Everything else the engine serves -
|
||||||
|
// the identity list, the gallery, erasure - stays unreachable through here
|
||||||
|
// even for a caller holding the token.
|
||||||
|
//
|
||||||
|
// frame.jpg is listed although no screen asks for one yet. It is reachable
|
||||||
|
// only through urlFor, which is internal, so it adds no bound API nobody
|
||||||
|
// calls; it is here so that adding a still later is a change to a screen
|
||||||
|
// rather than a change to the one file where a mistake is a credentialed
|
||||||
|
// proxy onto the biometric API.
|
||||||
|
var enginePath string
|
||||||
|
switch parts[3] {
|
||||||
|
case "stream.mjpeg":
|
||||||
|
enginePath = "/api/cameras/" + cameraID + "/stream.mjpeg"
|
||||||
|
case "frame.jpg":
|
||||||
|
enginePath = "/api/cameras/" + cameraID + "/frame.jpg"
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, target+enginePath, nil)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "bad upstream request", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// frame.jpg takes width and quality; the engine re-encodes on demand.
|
||||||
|
req.URL.RawQuery = r.URL.RawQuery
|
||||||
|
if user != "" {
|
||||||
|
req.SetBasicAuth(user, pass)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "engine unreachable", http.StatusBadGateway)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
for _, h := range []string{"Content-Type", "Cache-Control", "Pragma", "Expires"} {
|
||||||
|
if v := resp.Header.Get(h); v != "" {
|
||||||
|
w.Header().Set(h, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
w.WriteHeader(resp.StatusCode)
|
||||||
|
|
||||||
|
// Copied by hand rather than with io.Copy so every chunk is flushed. An
|
||||||
|
// MJPEG stream never ends, so anything buffered waiting for a full buffer
|
||||||
|
// is a tile that stays blank forever - which is the same symptom as the
|
||||||
|
// bug this file exists to fix, and would look like it had not worked.
|
||||||
|
flusher, _ := w.(http.Flusher)
|
||||||
|
buf := make([]byte, 32*1024)
|
||||||
|
for {
|
||||||
|
n, rerr := resp.Body.Read(buf)
|
||||||
|
if n > 0 {
|
||||||
|
if _, werr := w.Write(buf[:n]); werr != nil {
|
||||||
|
return // webview went away
|
||||||
|
}
|
||||||
|
if flusher != nil {
|
||||||
|
flusher.Flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if rerr != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
296
desktop/stream_proxy_test.go
Normal file
296
desktop/stream_proxy_test.go
Normal file
@@ -0,0 +1,296 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeEngine stands in for the Python engine: it demands Basic auth exactly as
|
||||||
|
// the real one does when a credential is configured, and records what it was
|
||||||
|
// asked for.
|
||||||
|
type fakeEngine struct {
|
||||||
|
*httptest.Server
|
||||||
|
gotPath string
|
||||||
|
gotUser string
|
||||||
|
gotPass string
|
||||||
|
hadAuth bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func newFakeEngine(t *testing.T, body string) *fakeEngine {
|
||||||
|
t.Helper()
|
||||||
|
f := &fakeEngine{}
|
||||||
|
f.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
f.gotPath = r.URL.Path
|
||||||
|
if r.URL.RawQuery != "" {
|
||||||
|
f.gotPath += "?" + r.URL.RawQuery
|
||||||
|
}
|
||||||
|
f.gotUser, f.gotPass, f.hadAuth = r.BasicAuth()
|
||||||
|
if !f.hadAuth {
|
||||||
|
w.Header().Set("WWW-Authenticate", `Basic realm="behavision"`)
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "multipart/x-mixed-replace; boundary=frame")
|
||||||
|
_, _ = io.WriteString(w, body)
|
||||||
|
}))
|
||||||
|
t.Cleanup(f.Close)
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
func startProxy(t *testing.T, engine string, user, pass string) *streamProxy {
|
||||||
|
t.Helper()
|
||||||
|
p := newStreamProxy()
|
||||||
|
if err := p.start(engine, user, pass); err != nil {
|
||||||
|
t.Fatalf("start: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(p.stop)
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func get(t *testing.T, url string) (int, string) {
|
||||||
|
t.Helper()
|
||||||
|
c := &http.Client{Timeout: 5 * time.Second}
|
||||||
|
resp, err := c.Get(url)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get %s: %v", url, err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
b, _ := io.ReadAll(resp.Body)
|
||||||
|
return resp.StatusCode, string(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The whole point: the webview gets a URL it can actually load, and the
|
||||||
|
// password stays behind. A credential in the src is both unloadable in a
|
||||||
|
// Chromium webview and readable by anything that can see the DOM.
|
||||||
|
func TestTheCameraURLCarriesNoPassword(t *testing.T) {
|
||||||
|
engine := newFakeEngine(t, "frames")
|
||||||
|
p := startProxy(t, engine.URL, "behavision", "hunter2-the-real-one")
|
||||||
|
|
||||||
|
u := p.urlFor("cam2", "stream.mjpeg")
|
||||||
|
if u == "" {
|
||||||
|
t.Fatal("no url while the proxy is running")
|
||||||
|
}
|
||||||
|
if strings.Contains(u, "hunter2-the-real-one") || strings.Contains(u, "behavision:") {
|
||||||
|
t.Fatalf("credential leaked into the tile URL: %s", u)
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(u, "http://127.0.0.1:") {
|
||||||
|
t.Fatalf("relay must be loopback only, got %s", u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheRelayAttachesTheCredentialItself(t *testing.T) {
|
||||||
|
engine := newFakeEngine(t, "frame-bytes")
|
||||||
|
p := startProxy(t, engine.URL, "behavision", "s3cret")
|
||||||
|
|
||||||
|
code, body := get(t, p.urlFor("cam2", "stream.mjpeg"))
|
||||||
|
if code != http.StatusOK {
|
||||||
|
t.Fatalf("want 200 through the relay, got %d", code)
|
||||||
|
}
|
||||||
|
if body != "frame-bytes" {
|
||||||
|
t.Fatalf("body not relayed unchanged: %q", body)
|
||||||
|
}
|
||||||
|
if !engine.hadAuth || engine.gotUser != "behavision" || engine.gotPass != "s3cret" {
|
||||||
|
t.Fatalf("engine did not receive the credential: auth=%v user=%q",
|
||||||
|
engine.hadAuth, engine.gotUser)
|
||||||
|
}
|
||||||
|
if engine.gotPath != "/api/cameras/cam2/stream.mjpeg" {
|
||||||
|
t.Fatalf("wrong upstream path: %s", engine.gotPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The token is what keeps every other process on a shop PC from opening a live
|
||||||
|
// view of customers' faces, now that the relay itself has no password.
|
||||||
|
func TestAnotherProcessCannotGuessItsWayIn(t *testing.T) {
|
||||||
|
engine := newFakeEngine(t, "frames")
|
||||||
|
p := startProxy(t, engine.URL, "behavision", "s3cret")
|
||||||
|
addr := p.ln.Addr().String()
|
||||||
|
|
||||||
|
for _, bad := range []string{"", "0", strings.Repeat("a", 64), "wrong-token"} {
|
||||||
|
url := fmt.Sprintf("http://%s/s/%s/cam2/stream.mjpeg", addr, bad)
|
||||||
|
if code, _ := get(t, url); code != http.StatusNotFound {
|
||||||
|
t.Fatalf("token %q got %d, want 404", bad, code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if engine.hadAuth {
|
||||||
|
t.Fatal("a rejected request still reached the engine")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A camera id is interpolated into the upstream path, so it has to be a camera
|
||||||
|
// id and not a way to walk to a different endpoint with the credential
|
||||||
|
// attached.
|
||||||
|
func TestACameraIdCannotClimbOutOfItsPath(t *testing.T) {
|
||||||
|
engine := newFakeEngine(t, "frames")
|
||||||
|
p := startProxy(t, engine.URL, "behavision", "s3cret")
|
||||||
|
addr := p.ln.Addr().String()
|
||||||
|
|
||||||
|
for _, bad := range []string{"..", "%2e%2e", "cam2/../../api/identities", "cam 2", ""} {
|
||||||
|
url := fmt.Sprintf("http://%s/s/%s/%s/stream.mjpeg", addr, p.token, bad)
|
||||||
|
code, _ := get(t, url)
|
||||||
|
if code != http.StatusNotFound {
|
||||||
|
t.Fatalf("camera id %q got %d, want 404", bad, code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(engine.gotPath, "identities") {
|
||||||
|
t.Fatalf("reached a non-camera endpoint: %s", engine.gotPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only the two files a tile needs. The engine also serves the identity list and
|
||||||
|
// the erasure endpoint; holding the token must not open those.
|
||||||
|
func TestOnlyTheTwoCameraFilesAreReachable(t *testing.T) {
|
||||||
|
engine := newFakeEngine(t, "frames")
|
||||||
|
p := startProxy(t, engine.URL, "behavision", "s3cret")
|
||||||
|
addr := p.ln.Addr().String()
|
||||||
|
|
||||||
|
for _, bad := range []string{"identities", "stats", "commission", "stream.mjpeg.bak"} {
|
||||||
|
url := fmt.Sprintf("http://%s/s/%s/cam2/%s", addr, p.token, bad)
|
||||||
|
if code, _ := get(t, url); code != http.StatusNotFound {
|
||||||
|
t.Fatalf("file %q got %d, want 404", bad, code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, good := range []string{"stream.mjpeg", "frame.jpg"} {
|
||||||
|
url := fmt.Sprintf("http://%s/s/%s/cam2/%s", addr, p.token, good)
|
||||||
|
if code, _ := get(t, url); code != http.StatusOK {
|
||||||
|
t.Fatalf("file %q got %d, want 200", good, code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// frame.jpg takes width and quality - the engine re-encodes on demand, and a
|
||||||
|
// relay that dropped the query would silently serve full-size frames.
|
||||||
|
func TestTheQueryStringSurvivesTheRelay(t *testing.T) {
|
||||||
|
engine := newFakeEngine(t, "frames")
|
||||||
|
p := startProxy(t, engine.URL, "behavision", "s3cret")
|
||||||
|
|
||||||
|
url := p.urlFor("cam2", "frame.jpg") + "?width=640&quality=70"
|
||||||
|
if code, _ := get(t, url); code != http.StatusOK {
|
||||||
|
t.Fatalf("got %d", code)
|
||||||
|
}
|
||||||
|
if !strings.Contains(engine.gotPath, "width=640") ||
|
||||||
|
!strings.Contains(engine.gotPath, "quality=70") {
|
||||||
|
t.Fatalf("query dropped: %s", engine.gotPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An engine that is not running must read as a bad gateway, not as a hang. A
|
||||||
|
// blank tile that never resolves is the symptom this whole file exists to end.
|
||||||
|
func TestAnEngineThatIsDownFailsQuickly(t *testing.T) {
|
||||||
|
// Port 1 on loopback: nothing listens, and the connection is refused
|
||||||
|
// rather than dropped, so this is fast and deterministic.
|
||||||
|
p := startProxy(t, "http://127.0.0.1:1", "behavision", "s3cret")
|
||||||
|
|
||||||
|
done := make(chan int, 1)
|
||||||
|
go func() { code, _ := get(t, p.urlFor("cam2", "stream.mjpeg")); done <- code }()
|
||||||
|
select {
|
||||||
|
case code := <-done:
|
||||||
|
if code != http.StatusBadGateway {
|
||||||
|
t.Fatalf("want 502, got %d", code)
|
||||||
|
}
|
||||||
|
case <-time.After(8 * time.Second):
|
||||||
|
t.Fatal("a dead engine left the request hanging")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stopping must actually free the port, or a restarted engine leaves listeners
|
||||||
|
// behind for the life of the process.
|
||||||
|
func TestStoppingReleasesEverything(t *testing.T) {
|
||||||
|
engine := newFakeEngine(t, "frames")
|
||||||
|
p := newStreamProxy()
|
||||||
|
if err := p.start(engine.URL, "u", "p"); err != nil {
|
||||||
|
t.Fatalf("start: %v", err)
|
||||||
|
}
|
||||||
|
url := p.urlFor("cam2", "stream.mjpeg")
|
||||||
|
if code, _ := get(t, url); code != http.StatusOK {
|
||||||
|
t.Fatalf("want 200 before stop, got %d", code)
|
||||||
|
}
|
||||||
|
|
||||||
|
p.stop()
|
||||||
|
|
||||||
|
if got := p.urlFor("cam2", "stream.mjpeg"); got != "" {
|
||||||
|
t.Fatalf("still handing out URLs after stop: %s", got)
|
||||||
|
}
|
||||||
|
c := &http.Client{Timeout: 3 * time.Second}
|
||||||
|
if resp, err := c.Get(url); err == nil {
|
||||||
|
resp.Body.Close()
|
||||||
|
t.Fatal("listener still accepting after stop")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// start twice must not leave two listeners, which is what a restarted engine
|
||||||
|
// would otherwise cause.
|
||||||
|
func TestStartingTwiceIsANoOp(t *testing.T) {
|
||||||
|
engine := newFakeEngine(t, "frames")
|
||||||
|
p := startProxy(t, engine.URL, "u", "p")
|
||||||
|
|
||||||
|
first := p.urlFor("cam2", "stream.mjpeg")
|
||||||
|
if err := p.start(engine.URL, "u", "p"); err != nil {
|
||||||
|
t.Fatalf("second start: %v", err)
|
||||||
|
}
|
||||||
|
if second := p.urlFor("cam2", "stream.mjpeg"); second != first {
|
||||||
|
t.Fatalf("second start moved the relay: %s -> %s", first, second)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Against the real engine, which the unit tests above deliberately do not
|
||||||
|
// touch. Skipped unless TEST_ENGINE_URL is set, the same rule the server's
|
||||||
|
// live store tests follow: the suite must stay runnable with no services.
|
||||||
|
//
|
||||||
|
// TEST_ENGINE_URL=http://127.0.0.1:8010 \
|
||||||
|
// TEST_ENGINE_USER=... TEST_ENGINE_PASS=... go test ./desktop/ -run Live
|
||||||
|
//
|
||||||
|
// It exists because everything above proves the relay against a fake that
|
||||||
|
// agrees with me. Only a real engine proves the thing that was actually
|
||||||
|
// broken: that a multipart MJPEG stream arrives through the relay in pieces,
|
||||||
|
// rather than being buffered into a tile that never paints.
|
||||||
|
func TestLiveRelayCarriesRealMJPEGFrames(t *testing.T) {
|
||||||
|
base := os.Getenv("TEST_ENGINE_URL")
|
||||||
|
if base == "" {
|
||||||
|
t.Skip("set TEST_ENGINE_URL to run the live relay test")
|
||||||
|
}
|
||||||
|
cam := os.Getenv("TEST_ENGINE_CAMERA")
|
||||||
|
if cam == "" {
|
||||||
|
cam = "cam2"
|
||||||
|
}
|
||||||
|
p := startProxy(t, base, os.Getenv("TEST_ENGINE_USER"), os.Getenv("TEST_ENGINE_PASS"))
|
||||||
|
|
||||||
|
url := p.urlFor(cam, "stream.mjpeg")
|
||||||
|
req, _ := http.NewRequest(http.MethodGet, url, nil)
|
||||||
|
resp, err := (&http.Client{}).Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("relay: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("relay returned %d - the credential did not reach the engine", resp.StatusCode)
|
||||||
|
}
|
||||||
|
if ct := resp.Header.Get("Content-Type"); !strings.Contains(ct, "multipart") {
|
||||||
|
t.Fatalf("not a stream: Content-Type %q", ct)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read until two JPEG start markers have gone past. One proves it opened;
|
||||||
|
// two prove it is still delivering, which is the difference between a
|
||||||
|
// working tile and a single frozen frame.
|
||||||
|
deadline := time.Now().Add(15 * time.Second)
|
||||||
|
var seen, total int
|
||||||
|
buf := make([]byte, 16*1024)
|
||||||
|
for seen < 2 && time.Now().Before(deadline) {
|
||||||
|
n, rerr := resp.Body.Read(buf)
|
||||||
|
total += n
|
||||||
|
seen += strings.Count(string(buf[:n]), "\xff\xd8\xff")
|
||||||
|
if rerr != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if seen < 2 {
|
||||||
|
t.Fatalf("only %d JPEG frames in %d bytes - the relay is not streaming", seen, total)
|
||||||
|
}
|
||||||
|
t.Logf("relayed %d frames in %d bytes with no credential in the URL", seen, total)
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -10,6 +11,25 @@ import (
|
|||||||
"github.com/wailsapp/wails/v2/pkg/runtime"
|
"github.com/wailsapp/wails/v2/pkg/runtime"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// BEHAVISION_NO_TRAY runs the window with no tray icon.
|
||||||
|
//
|
||||||
|
// It exists so the UI can be looked at on a Mac. fyne.io/systray's nativeLoop
|
||||||
|
// must own the main thread on macOS - a Cocoa requirement, not a library
|
||||||
|
// choice - and Wails already holds it, so starting both kills the process with
|
||||||
|
// a SIGTRAP inside cgo before a single screen is drawn. On Windows, which is
|
||||||
|
// what ships, a tray on its own goroutine is fine. That asymmetry is why this
|
||||||
|
// went unnoticed for so long: the shop-floor UI had never once been run on the
|
||||||
|
// platform it is developed on, so every screen in it was unreviewed.
|
||||||
|
//
|
||||||
|
// Deliberately an environment variable and NOT a GOOS check. A build that
|
||||||
|
// quietly drops the tray on some platform is how a shop PC ends up with no
|
||||||
|
// control surface at all - the one thing a shop manager has - and it would
|
||||||
|
// fail exactly where nobody is watching. Nothing is skipped unless a person
|
||||||
|
// asked for it, by name, on this run.
|
||||||
|
const noTrayEnv = "BEHAVISION_NO_TRAY"
|
||||||
|
|
||||||
|
func trayDisabled() bool { return os.Getenv(noTrayEnv) != "" }
|
||||||
|
|
||||||
// tray is the always-present control surface. Wails v2 has no systray of its
|
// tray is the always-present control surface. Wails v2 has no systray of its
|
||||||
// own, so this drives fyne.io/systray alongside the window.
|
// own, so this drives fyne.io/systray alongside the window.
|
||||||
//
|
//
|
||||||
@@ -32,6 +52,9 @@ type tray struct {
|
|||||||
func newTray(a *App) *tray { return &tray{app: a, quit: make(chan struct{})} }
|
func newTray(a *App) *tray { return &tray{app: a, quit: make(chan struct{})} }
|
||||||
|
|
||||||
func (t *tray) start(ctx context.Context) {
|
func (t *tray) start(ctx context.Context) {
|
||||||
|
if trayDisabled() {
|
||||||
|
return
|
||||||
|
}
|
||||||
t.once.Do(func() {
|
t.once.Do(func() {
|
||||||
go systray.Run(func() { t.onReady(ctx) }, func() {})
|
go systray.Run(func() { t.onReady(ctx) }, func() {})
|
||||||
})
|
})
|
||||||
@@ -43,6 +66,13 @@ func (t *tray) stop() {
|
|||||||
default:
|
default:
|
||||||
close(t.quit)
|
close(t.quit)
|
||||||
}
|
}
|
||||||
|
// systray.Quit() on a systray that was never started is not a no-op in
|
||||||
|
// v1.12.2, so the guard has to be on both ends or quitting the window
|
||||||
|
// takes the process down with it - a crash on exit, which is the failure
|
||||||
|
// most likely to be shrugged off as "it closed, fine".
|
||||||
|
if trayDisabled() {
|
||||||
|
return
|
||||||
|
}
|
||||||
systray.Quit()
|
systray.Quit()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,38 @@ func liveStore(t *testing.T) *Store {
|
|||||||
return st
|
return st
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// dropTenant removes a seeded tenant when the test that made it finishes.
|
||||||
|
//
|
||||||
|
// Without this these tests are a slow leak. Every one of them seeds its own
|
||||||
|
// tenant - deliberately, so they can run in any order and so the isolation
|
||||||
|
// assertions have a real neighbour - and none of them ever removed it. A dev
|
||||||
|
// database reached 242 abandoned tenants against the single real one, which is
|
||||||
|
// not merely untidy: the platform admin's Companies screen lists every client,
|
||||||
|
// so the one real company was buried under pages of `walk1788761685056287000`.
|
||||||
|
//
|
||||||
|
// Registered against the CLIENT rather than each table because every foreign
|
||||||
|
// key onto clients is ON DELETE CASCADE, so one delete takes the sites,
|
||||||
|
// visitors, visits, face images, embeddings, cameras and agents with it. A
|
||||||
|
// per-table list would rot the first time a migration adds a table, and it
|
||||||
|
// would rot silently - which is the shape of the bug it is cleaning up after.
|
||||||
|
//
|
||||||
|
// t.Cleanup runs LIFO and liveStore registers st.Close before any seeding, so
|
||||||
|
// the delete still has a live pool when it runs.
|
||||||
|
func dropTenant(t *testing.T, st *Store, clientID string) {
|
||||||
|
t.Helper()
|
||||||
|
t.Cleanup(func() {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if _, err := st.pool.Exec(ctx,
|
||||||
|
`DELETE FROM clients WHERE id = $1::uuid`, clientID); err != nil {
|
||||||
|
// Reported rather than ignored: a tenant left behind is the very
|
||||||
|
// thing this exists to prevent, and swallowing the error would let
|
||||||
|
// the leak return with nothing to show for it.
|
||||||
|
t.Errorf("cleanup tenant %s: %v", clientID, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// seedTenant builds a client, a site and n visits, and returns the client id.
|
// seedTenant builds a client, a site and n visits, and returns the client id.
|
||||||
// Every test gets its own tenant so they can run in any order without a
|
// Every test gets its own tenant so they can run in any order without a
|
||||||
// truncate between them - and so the isolation assertions below have a real
|
// truncate between them - and so the isolation assertions below have a real
|
||||||
@@ -52,6 +84,7 @@ func seedTenant(t *testing.T, st *Store, name string, n int, withImages bool) (c
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("seed client: %v", err)
|
t.Fatalf("seed client: %v", err)
|
||||||
}
|
}
|
||||||
|
dropTenant(t, st, clientID)
|
||||||
err = st.pool.QueryRow(ctx, `
|
err = st.pool.QueryRow(ctx, `
|
||||||
INSERT INTO sites (client_id, name, slug) VALUES ($1::uuid, $2, $3)
|
INSERT INTO sites (client_id, name, slug) VALUES ($1::uuid, $2, $3)
|
||||||
RETURNING id::text`, clientID, name+" Main", name+"-main").Scan(&siteID)
|
RETURNING id::text`, clientID, name+" Main", name+"-main").Scan(&siteID)
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ func seedAgentSite(t *testing.T, st *Store, name string) ingest.Site {
|
|||||||
name).Scan(&site.ClientID); err != nil {
|
name).Scan(&site.ClientID); err != nil {
|
||||||
t.Fatalf("seed client: %v", err)
|
t.Fatalf("seed client: %v", err)
|
||||||
}
|
}
|
||||||
|
dropTenant(t, st, site.ClientID)
|
||||||
if err := st.pool.QueryRow(ctx, `
|
if err := st.pool.QueryRow(ctx, `
|
||||||
INSERT INTO sites (client_id, name, slug) VALUES ($1::uuid, $2, $3)
|
INSERT INTO sites (client_id, name, slug) VALUES ($1::uuid, $2, $3)
|
||||||
RETURNING id::text`, site.ClientID, name, name).Scan(&site.SiteID); err != nil {
|
RETURNING id::text`, site.ClientID, name, name).Scan(&site.SiteID); err != nil {
|
||||||
|
|||||||
Reference in New Issue
Block a user