Files
Privatae e4c9b69b46
ci / personal-data (push) Successful in 5s
ci / test (push) Successful in 3m4s
ci / fuzz (push) Successful in 3m45s
bastion 0.53.2
2026-10-20 04:42:14 +00:00

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
}
}
}()
}