185 lines
7.3 KiB
Go
185 lines
7.3 KiB
Go
//go:build windows
|
|
|
|
package main
|
|
|
|
// tray_windows.go — the icon in the notification area, which is the only part of
|
|
// this UI that answers "is it running" without anyone opening anything.
|
|
//
|
|
// WHY WINDOWS ONLY, AND WHY THAT IS NOT ARBITRARY. deploy/publish.sh builds all
|
|
// six targets in ONE alpine container at CGO_ENABLED=0, which is what makes the
|
|
// bastion a static binary with no runtime dependencies. Measured with
|
|
// fyne.io/systray v1.12.2 against every one of those targets:
|
|
//
|
|
// windows/amd64 OK windows/arm64 OK
|
|
// linux/amd64 OK linux/arm64 OK (pure-Go D-Bus/AppIndicator)
|
|
// darwin/amd64 FAIL darwin/arm64 FAIL (undefined: nativeLoop — Cocoa, cgo)
|
|
//
|
|
// So an untagged import would break half the release. Behind //go:build windows
|
|
// the darwin files are never compiled and the build is unchanged.
|
|
//
|
|
// Linux is left out on purpose rather than because it cannot: those boxes are
|
|
// headless servers running under systemd, where `systemctl status` already
|
|
// answers liveness and there is no notification area to put an icon in. macOS
|
|
// would need cgo and gets the web UI plus a launchd agent instead. If a desktop
|
|
// Linux user ever needs this, the tag is the only thing in the way.
|
|
//
|
|
// THREADING, which is the part that silently does not work if you get it wrong.
|
|
// A Windows tray icon belongs to a window, a window belongs to the thread that
|
|
// created it, and GetMessage only ever returns messages for its OWN thread.
|
|
// systray.Register calls CreateWindowEx synchronously on the calling goroutine
|
|
// (systray_windows.go:442) and systray.Run then pumps on that same goroutine —
|
|
// so Run must be called from a goroutine pinned to one OS thread. The package's
|
|
// init() locks the MAIN goroutine, which is right if you call Run from main();
|
|
// this daemon cannot, because cmdRun owns main. So this file locks its own.
|
|
// RunWithExternalLoop is the trap: its start func pumps on a fresh, unlocked
|
|
// goroutine, which is a different thread from the one holding the window.
|
|
|
|
import (
|
|
"context"
|
|
"embed"
|
|
"runtime"
|
|
"time"
|
|
|
|
"fyne.io/systray"
|
|
)
|
|
|
|
//go:embed uiassets/tray-ok.ico uiassets/tray-warn.ico uiassets/tray-bad.ico
|
|
var trayIconFS embed.FS
|
|
|
|
func trayIcon(class string) []byte {
|
|
b, err := trayIconFS.ReadFile("uiassets/tray-" + class + ".ico")
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
return b
|
|
}
|
|
|
|
// startTray puts the icon up and keeps it current until ctx is done.
|
|
//
|
|
// Every failure here is non-fatal and silent-but-logged by design: a machine
|
|
// with no interactive session (a task started before logon, a locked-down
|
|
// desktop policy) must still serve its hosts. The tray is how you SEE the
|
|
// bastion, never how it runs.
|
|
func startTray(ctx context.Context) {
|
|
// A service runs in a session with no desktop, so there is nowhere to put
|
|
// an icon, and the approval notifier the tray starts would have no one to
|
|
// show. Beside a service, the signed-in person's icon is the `tray` verb,
|
|
// started at their logon (tray_pipe_windows.go). trayFor (tray.go) is the
|
|
// whole rule; the first test is kept literal for TestServiceModeOpensNothingOnTheDesktop.
|
|
if runningAsService {
|
|
return
|
|
}
|
|
if trayFor(runtime.GOOS, runningAsService, systemService.installed, "run") != trayInProcess {
|
|
return
|
|
}
|
|
go func() {
|
|
// See the threading note above — this is the load-bearing line.
|
|
runtime.LockOSThread()
|
|
defer runtime.UnlockOSThread()
|
|
systray.Run(func() { trayReady(ctx) }, func() {})
|
|
}()
|
|
go func() {
|
|
<-ctx.Done()
|
|
systray.Quit()
|
|
}()
|
|
}
|
|
|
|
func trayReady(ctx context.Context) {
|
|
systray.SetIcon(trayIcon("bad"))
|
|
systray.SetTooltip("Privatae bastion")
|
|
|
|
mState := systray.AddMenuItem("Checking…", "")
|
|
mState.Disable() // a label, not a control
|
|
// THE APPROVALS LINE, first under the state so a waiting approval is one
|
|
// click from the icon: it opens the approvals popup (uidevice.go), never a
|
|
// menu. Disabled and saying so when nothing is waiting; its words are
|
|
// trayApprovalsLine (tray.go), tested off Windows.
|
|
mApprovals := systray.AddMenuItem(trayApprovalsLine(0), "Open what your hosts are waiting on you for")
|
|
mApprovals.Disable()
|
|
systray.AddSeparator()
|
|
mOpen := systray.AddMenuItem("Open Privatae…", "See what this machine is doing")
|
|
// YOUR CONTEXT, FROM THE TRAY. A person who sees the tray icon expects the
|
|
// way in to be on its menu, and the entry is named for what it serves: not
|
|
// files, but the context built from files — the App determines what becomes
|
|
// context on the network.
|
|
//
|
|
// So the menu says CONTEXT, not files. Files are what you look at; context
|
|
// is what you decide they become, and the deciding is the whole point of
|
|
// the surface. It also shipped as a verb you type, which is the terminal
|
|
// this tray exists to remove — a feature reachable only from a command line
|
|
// does not exist for the person who asked for it.
|
|
mFiles := systray.AddMenuItem("Your context…",
|
|
"Choose what your files become on your network")
|
|
systray.AddSeparator()
|
|
mQuit := systray.AddMenuItem("Stop the bastion",
|
|
"Stops serving every host on this machine until you start it again")
|
|
|
|
// The approvals lane rides THIS poll rather than starting its own.
|
|
//
|
|
// One reason of substance and one of cost. The substance: startApprovalNotifier
|
|
// needs a window station to put an icon on, and trayReady only runs when
|
|
// systray came up, which is the same condition — a machine with no
|
|
// interactive session (a task with a background logon, a locked-down desktop
|
|
// policy) has nobody to notify, so a notifier that ran there would be
|
|
// raising notifications into an empty room. The cost: collectUIStatus probes
|
|
// every pairing with a TCP connect (ui.go:152), and a second poll would
|
|
// double that on a laptop battery for a list this one already has.
|
|
notes := &approvalNotifier{}
|
|
sink := startApprovalNotifier(ctx)
|
|
|
|
var last string
|
|
refresh := func() {
|
|
s := collectUIStatus()
|
|
if s.StateClass != last {
|
|
if b := trayIcon(s.StateClass); b != nil {
|
|
systray.SetIcon(b)
|
|
}
|
|
last = s.StateClass
|
|
}
|
|
// SetTooltip is capped at 128 chars on Windows, so this stays one line.
|
|
systray.SetTooltip("Privatae bastion — " + s.State)
|
|
mState.SetTitle(trayStateLine(s))
|
|
// Everything waiting on the person: every pairing's step-up requests
|
|
// (collectUIStatus appends them machine-wide) and the asks waiting on
|
|
// this machine as the approval device — the list the notification's
|
|
// count, and the approvals line, have to be about.
|
|
pending := approvalsForNotice(s)
|
|
mApprovals.SetTitle(trayApprovalsLine(len(pending)))
|
|
if len(pending) > 0 {
|
|
mApprovals.Enable()
|
|
} else {
|
|
mApprovals.Disable()
|
|
}
|
|
notes.poll(time.Now(), pending, sink)
|
|
}
|
|
refresh()
|
|
|
|
go func() {
|
|
t := time.NewTicker(trayRefresh)
|
|
defer t.Stop()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-t.C:
|
|
refresh()
|
|
case <-mApprovals.ClickedCh:
|
|
// Off the menu goroutine for the same reason the notification
|
|
// click is (notify_windows.go): minting the ticket is an HTTP
|
|
// round trip, and this loop is what keeps the icon current.
|
|
go openApprovalsPopup("tray")
|
|
case <-mOpen.ClickedCh:
|
|
openLocalUI("tray")
|
|
case <-mFiles.ClickedCh:
|
|
openContextSurface("tray")
|
|
case <-mQuit.ClickedCh:
|
|
// Not systray.Quit() alone — that would take the icon away and
|
|
// leave the daemon serving invisibly, which is the exact state
|
|
// this whole UI exists to end.
|
|
requestShutdown()
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
}
|