Edit murmur.svg for the five symbol shapes, or wordmark.svg for the lettering. Run python3 scripts/generate-brand.py to regenerate the variants and PNG exports. PNG rendering uses rsvg-convert; --svg-only generates just the vector variants. The master files are preserved. The selected geometry was traced from the approved concept artwork.
diff --git a/docs/specs/2026-10-24-desktop-app-design.md b/docs/specs/2026-10-24-desktop-app-design.md
index a61f7ca7..5b4aaaa4 100644
--- a/docs/specs/2026-10-24-desktop-app-design.md
+++ b/docs/specs/2026-10-24-desktop-app-design.md
@@ -103,13 +103,16 @@ the same click behaviour. The headless service keeps its own notifier.
The app watches its executable's version. When `brew upgrade` (or the Windows
installer) replaces it, the app stops its child and relaunches itself, so an
old app never reads a store migrated by a newer collector (`ErrSchemaNewer`).
+If the quit is cancelled, no collector starts under the old app: the tray
+says "Restart Driga to finish updating" and the app asks again on its next
+check.
### Executables
- macOS: one binary, `Driga.app/Contents/MacOS/driga`, the CLI and the app.
Homebrew links it as `driga`. `driga app` hands off to Launch Services
- (`open -b run.driga`) so the login item and notification identity are the
- bundle's.
+ (`open -a` with the bundle's path) so the login item and notification
+ identity are the bundle's.
- Windows: `Driga.exe` (GUI subsystem, the app) and `bin\driga.exe` (console
subsystem, the CLI), built from the same packages, like VS Code's `Code.exe`
and `code`. The collector child is started with `CREATE_NO_WINDOW`.
@@ -158,14 +161,13 @@ stay. Alpha: no compatibility path.
(Keyboard Shortcuts, Documentation opens driga.dev in the browser). Ctrl
replaces Cmd on Windows and Linux. Menu items send the page's existing
commands (the same functions its `m`, `t`, `s`, `/` keys call).
-- **Tray.** Template icon from the Driga mark. macOS shows the needs-you count
- as its title. Menu: Open Driga, the first five needs (each opens that
+- **Tray.** An icon only, never text: the Driga mark (a template on macOS),
+ with a dot while anything needs you. Menu: Open Driga, the first five needs (each opens that
session), collector status ("Collecting", or who else is, or the error),
Quit. Off with `app.tray = false`.
-- **Count.** Dock badge on macOS, launcher badge on Linux where supported.
- Windows has neither a tray title nor a badge in mygo, so the count shows in
- the tray tooltip and the tray menu's first line, and the tray icon switches
- to a variant with a dot while anything needs you.
+- **Count.** Dock badge on macOS, launcher badge on Linux where supported. On
+ every platform the tray tooltip and the tray menu's first line say how many
+ need you.
- **Dialogs.** Stop asks with a native dialog parented to the window ("Stop
‹title›?" Stop / Cancel). Every `confirm()` in the page becomes a native
dialog.
diff --git a/docs/usage.md b/docs/usage.md
index a04b65d7..8c7b4607 100644
--- a/docs/usage.md
+++ b/docs/usage.md
@@ -773,10 +773,11 @@ retained Activity history. Chat opens as a contextual panel on `c`; Machines
includes process and disk reclamation. Stop and other confirmations use native
dialogs. Help → Keyboard Shortcuts lists the page keys.
-The menu bar item (the notification area on Windows, the tray on Linux) shows
-how many sessions need you, lists the first five, says who is collecting and
-quits Driga. On macOS the Dock icon shows the count too, and appears only while
-a window is open. Settings (Cmd+,) sets the theme (`ui.theme`), the tray icon
+The menu bar item (the notification area on Windows, the tray on Linux) is an
+icon only: it gains a dot while anything needs you, and its tooltip and menu
+say how many. The menu lists the first five, says who is collecting and quits
+Driga. On macOS the Dock icon shows the count as a badge, and appears only
+while a window is open. Settings (Cmd+,) sets the theme (`ui.theme`), the tray icon
(`app.tray`) and launch at login (`app.launch_at_login`). A second launch,
`driga app` included, brings the running window forward.
diff --git a/internal/chat/remote_deadlines_test.go b/internal/chat/remote_deadlines_test.go
index 06a3a26c..5cba3a3d 100644
--- a/internal/chat/remote_deadlines_test.go
+++ b/internal/chat/remote_deadlines_test.go
@@ -58,3 +58,21 @@ func TestRemoteRequestTimeouts(t *testing.T) {
})
}
}
+
+// A long poll must outlive the broker's wait, which answers "no change" once
+// it ends; a shorter client deadline would turn every quiet poll into an error.
+func TestRemoteWaitOutlivesBrokerWait(t *testing.T) {
+ c := &Remote{http: &http.Client{}, transport: &http.Transport{}}
+ want := brokerWait + 10*time.Second
+ c.http.Transport = deadlineTransport(func(r *http.Request) (*http.Response, error) {
+ deadline, ok := r.Context().Deadline()
+ remaining := time.Until(deadline)
+ if !ok || remaining > want || remaining < want-time.Second {
+ t.Errorf("deadline %v; want %v", remaining, want)
+ }
+ return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(`{}`)), Header: make(http.Header)}, nil
+ })
+ if _, err := c.Wait(context.Background(), "id", 3); err != nil {
+ t.Fatal(err)
+ }
+}
diff --git a/internal/chat/transport.go b/internal/chat/transport.go
index eb6ca081..81274ac5 100644
--- a/internal/chat/transport.go
+++ b/internal/chat/transport.go
@@ -202,7 +202,7 @@ func handler(b *Broker) http.Handler {
}
if err == nil {
if r.URL.Path == "/wait" {
- ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
+ ctx, cancel := context.WithTimeout(r.Context(), brokerWait)
out, err = b.Wait(ctx, r.URL.Query().Get("id"), seq)
cancel()
if errors.Is(err, context.DeadlineExceeded) && r.Context().Err() == nil {
@@ -288,6 +288,10 @@ func (c *Remote) callOnce(ctx context.Context, method, path string, in, out any,
timeout := 10 * time.Second
if path == "/approve" || path == "/hook-prompt" {
timeout = ApprovalTTL + time.Minute
+ } else if strings.HasPrefix(path, "/wait?") {
+ // The broker answers a quiet wait with the current state once
+ // brokerWait passes; the request must last until then.
+ timeout = brokerWait + 10*time.Second
}
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
@@ -491,6 +495,10 @@ func (b *Broker) ensureSocket() error {
return nil
}
+// brokerWait bounds one long poll at the broker, which then answers with the
+// current state.
+const brokerWait = 15 * time.Second
+
func (c *Remote) Wait(ctx context.Context, id string, after uint64) (State, error) {
var out State
err := c.call(ctx, "GET", fmt.Sprintf("/wait?id=%s&after=%d", id, after), nil, &out)
diff --git a/internal/cli/app.go b/internal/cli/app.go
index cbaf17ab..aff39d8d 100644
--- a/internal/cli/app.go
+++ b/internal/cli/app.go
@@ -20,6 +20,8 @@ var (
// openedAtLogin and runDesktop are the desktop package's, replaced in tests.
openedAtLogin = desktop.OpenedAtLogin
runDesktop = desktop.Run
+ // openAppViewer opens the app's data; tests count its calls.
+ openAppViewer = openViewer
// systemLaunch records that the system started this process as the app:
// Finder, the Dock or Launch Services (DRIGA_APP=1), the login item, or
// the Windows app executable.
@@ -69,15 +71,14 @@ func (AppCmd) Run(g *Globals) error {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
g.Machine = "all"
- source, _, closeSource, err := openViewer(ctx, g, cfg, view.Filter{})
- if err != nil {
- return err
- }
- defer closeSource()
local := *g
local.Machine = "local"
return runDesktop(desktop.Options{
- Source: source,
+ // The app opens the viewer once it is the only instance.
+ OpenSource: func() (web.Source, func(), error) {
+ source, _, closeSource, err := openAppViewer(ctx, g, cfg, view.Filter{})
+ return source, closeSource, err
+ },
Web: web.Options{SessionAction: func(ctx context.Context, action string, id int64) error {
raw, _ := json.Marshal(map[string]any{"session_id": id})
actionGlobals := local
diff --git a/internal/cli/app_darwin.go b/internal/cli/app_darwin.go
index c0ad511f..e970985c 100644
--- a/internal/cli/app_darwin.go
+++ b/internal/cli/app_darwin.go
@@ -22,9 +22,13 @@ func handOffApp(system bool) (bool, error) {
if !handsOff(system, exe) {
return false, nil
}
- return true, exec.Command("open", "-b", "run.driga").Run()
+ return true, handOffCommand(appBundle(exe)).Run()
}
+// handOffCommand opens bundle by its path: another copy of Driga.app, such
+// as one in ~/Applications, has the same bundle identifier.
+func handOffCommand(bundle string) *exec.Cmd { return exec.Command("open", "-a", bundle) }
+
// handsOff reports whether to hand off: a launch not made by the system, of
// an executable inside an app bundle. A development build runs in place.
func handsOff(system bool, exe string) bool {
diff --git a/internal/cli/app_darwin_test.go b/internal/cli/app_darwin_test.go
index 384559db..30bb6af3 100644
--- a/internal/cli/app_darwin_test.go
+++ b/internal/cli/app_darwin_test.go
@@ -1,6 +1,9 @@
package cli
-import "testing"
+import (
+ "slices"
+ "testing"
+)
func TestAppBundle(t *testing.T) {
for _, tt := range []struct{ exe, want string }{
@@ -32,3 +35,12 @@ func TestAppHandOff(t *testing.T) {
}
}
}
+
+// The hand-off opens the bundle the command line ran from, not whichever
+// bundle Launch Services knows by the identifier.
+func TestAppHandOffCommand(t *testing.T) {
+ cmd := handOffCommand("/Users/someone/Applications/Driga.app")
+ if want := []string{"open", "-a", "/Users/someone/Applications/Driga.app"}; !slices.Equal(cmd.Args, want) {
+ t.Fatalf("hand-off runs %q, want %q", cmd.Args, want)
+ }
+}
diff --git a/internal/cli/app_test.go b/internal/cli/app_test.go
index 896d093b..6116c89d 100644
--- a/internal/cli/app_test.go
+++ b/internal/cli/app_test.go
@@ -2,10 +2,14 @@ package cli
import (
"bytes"
+ "context"
"os"
"testing"
+ "github.com/labstack/driga/internal/config"
"github.com/labstack/driga/internal/desktop"
+ "github.com/labstack/driga/internal/view"
+ "github.com/labstack/driga/internal/viewer"
)
func TestAppLaunch(t *testing.T) {
@@ -77,6 +81,14 @@ func TestAppCommandRunsDesktop(t *testing.T) {
defer func(prev func(desktop.Options) error) { runDesktop = prev }(runDesktop)
var got desktop.Options
var env string
+ opened := 0
+ defer func(prev func(context.Context, *Globals, config.Config, view.Filter) (viewer.ScopedSource, string, func(), error)) {
+ openAppViewer = prev
+ }(openAppViewer)
+ openAppViewer = func(ctx context.Context, g *Globals, cfg config.Config, f view.Filter) (viewer.ScopedSource, string, func(), error) {
+ opened++
+ return openViewer(ctx, g, cfg, f)
+ }
runDesktop = func(opts desktop.Options) error {
got, env = opts, os.Getenv("DRIGA_APP")
return nil
@@ -94,9 +106,17 @@ func TestAppCommandRunsDesktop(t *testing.T) {
if env != "" {
t.Fatalf("app started with DRIGA_APP=%q", env)
}
- if got.Source == nil || got.Supervisor == nil || got.Version != "test-version" {
+ if got.OpenSource == nil || got.Supervisor == nil || got.Version != "test-version" {
t.Fatalf("options: %+v", got)
}
+ if opened != 0 {
+ t.Fatal("the viewer opened before the app asked for it")
+ }
+ source, closeSource, err := got.OpenSource()
+ if err != nil || source == nil || opened != 1 {
+ t.Fatalf("open source: %v, %v, opened %d", source, err, opened)
+ }
+ closeSource()
if got.Web.SessionAction == nil || got.Web.CleanupList == nil || got.Web.Activity == nil || got.Web.Machine != "all" {
t.Fatalf("page handlers: %+v", got.Web)
}
diff --git a/internal/cli/collect.go b/internal/cli/collect.go
index 28a3be1d..781e97ab 100644
--- a/internal/cli/collect.go
+++ b/internal/cli/collect.go
@@ -50,14 +50,16 @@ func (c CollectCmd) Run(g *Globals) error {
// program that would inherit them too.
var notifyFile, parentFile *os.File
if c.NotifyFD != nil {
- if notifyFile = inheritedFile(*c.NotifyFD, "notify"); notifyFile == nil {
- return invalidUsage("--notify-fd is invalid")
+ var err error
+ if notifyFile, err = inheritedFile(*c.NotifyFD, "notify"); err != nil {
+ return invalidUsage("--notify-fd is not open")
}
defer notifyFile.Close()
}
if c.ParentFD != nil {
- if parentFile = inheritedFile(*c.ParentFD, "parent"); parentFile == nil {
- return invalidUsage("--parent-fd is invalid")
+ var err error
+ if parentFile, err = inheritedFile(*c.ParentFD, "parent"); err != nil {
+ return invalidUsage("--parent-fd is not open")
}
defer parentFile.Close()
}
diff --git a/internal/cli/collect_fd_test.go b/internal/cli/collect_fd_test.go
new file mode 100644
index 00000000..5add5908
--- /dev/null
+++ b/internal/cli/collect_fd_test.go
@@ -0,0 +1,35 @@
+package cli
+
+import (
+ "os"
+ "strings"
+ "testing"
+)
+
+// closedFD is a descriptor number that was open and is now closed.
+func closedFD(t *testing.T) int {
+ t.Helper()
+ r, w, err := os.Pipe()
+ if err != nil {
+ t.Fatal(err)
+ }
+ fd := int(r.Fd())
+ r.Close()
+ w.Close()
+ return fd
+}
+
+// A descriptor the parent did not pass fails at once, before the collector
+// runs with a notification pipe or lifeline that is not there.
+func TestCollectRejectsClosedDescriptors(t *testing.T) {
+ notify := closedFD(t)
+ err := CollectCmd{NotifyFD: ¬ify}.Run(nil)
+ if err == nil || !strings.Contains(err.Error(), "--notify-fd is not open") {
+ t.Fatalf("closed notify fd: %v", err)
+ }
+ parent := closedFD(t)
+ err = CollectCmd{ParentFD: &parent}.Run(nil)
+ if err == nil || !strings.Contains(err.Error(), "--parent-fd is not open") {
+ t.Fatalf("closed parent fd: %v", err)
+ }
+}
diff --git a/internal/cli/collect_fd_unix.go b/internal/cli/collect_fd_unix.go
index f7064bbd..4d894bf4 100644
--- a/internal/cli/collect_fd_unix.go
+++ b/internal/cli/collect_fd_unix.go
@@ -7,10 +7,14 @@ import (
"syscall"
)
-// inheritedFile opens a descriptor passed by the parent process. It is marked
-// close-on-exec so programs the collector runs do not hold the parent's pipes
-// open after the collector exits.
-func inheritedFile(fd int, name string) *os.File {
+// inheritedFile opens a descriptor passed by the parent process, or reports
+// that it is not open. It is marked close-on-exec so programs the collector
+// runs do not hold the parent's pipes open after the collector exits.
+func inheritedFile(fd int, name string) (*os.File, error) {
+ var st syscall.Stat_t
+ if err := syscall.Fstat(fd, &st); err != nil {
+ return nil, err
+ }
syscall.CloseOnExec(fd)
- return os.NewFile(uintptr(fd), name)
+ return os.NewFile(uintptr(fd), name), nil
}
diff --git a/internal/cli/collect_fd_windows.go b/internal/cli/collect_fd_windows.go
index 28402e47..474723e9 100644
--- a/internal/cli/collect_fd_windows.go
+++ b/internal/cli/collect_fd_windows.go
@@ -1,7 +1,16 @@
package cli
-import "os"
+import (
+ "os"
+ "syscall"
+)
-// inheritedFile opens a handle passed by the parent process. Windows children
-// inherit only the handles a process lists when it starts them.
-func inheritedFile(fd int, name string) *os.File { return os.NewFile(uintptr(fd), name) }
+// inheritedFile opens a handle passed by the parent process, or reports that
+// it is not open. Windows children inherit only the handles a process lists
+// when it starts them.
+func inheritedFile(fd int, name string) (*os.File, error) {
+ if _, err := syscall.GetFileType(syscall.Handle(fd)); err != nil {
+ return nil, err
+ }
+ return os.NewFile(uintptr(fd), name), nil
+}
diff --git a/internal/desktop/app.go b/internal/desktop/app.go
index 745260c6..b47fe3df 100644
--- a/internal/desktop/app.go
+++ b/internal/desktop/app.go
@@ -33,7 +33,10 @@ const loginMarker = "open-at-login"
// Options configure the desktop app.
type Options struct {
- Source web.Source
+ // OpenSource opens the data the app shows, and its closer. Run calls it
+ // once it holds the single-instance lock, so a second launch never opens
+ // the store.
+ OpenSource func() (web.Source, func(), error)
// Web carries the page handlers the CLI provides: session actions,
// cleanup, the machine scope and activity. Run sets its Chat, Confirm,
// OpenExternal and Theme.
@@ -50,6 +53,7 @@ type Options struct {
// shell is the running app: the main window, the tray and the collector.
type shell struct {
opts Options
+ source web.Source
goos string
dataDir string
host *windowHost
@@ -63,6 +67,8 @@ type shell struct {
openService func() (installedService, error)
// homes returns HOME and the account's home directory.
homes func() (string, string)
+ // loginEnv is the login environment the collector starts with.
+ loginEnv *loginEnv
// relaunchApp quits and starts the app again, and reports false when
// the quit was cancelled.
relaunchApp func() bool
@@ -78,9 +84,11 @@ type shell struct {
status Status
envErr error
serviceErr error
- needs []view.BoardRow
- trayKey string
- settings *mygo.Window
+ // restartPending is set once an upgrade's relaunch was cancelled.
+ restartPending bool
+ needs []view.BoardRow
+ trayKey string
+ settings *mygo.Window
}
// Run runs the desktop app until it quits. It must be called on the main
@@ -88,16 +96,18 @@ type shell struct {
func Run(opts Options) error {
mygo.App.SetName("Driga")
mygo.App.SetVersion(opts.Version)
- if !mygo.App.RequestSingleInstanceLock() {
- return nil
+ source, closeSource, first, err := openAfterLock(mygo.App.RequestSingleInstanceLock, opts.OpenSource)
+ if !first || err != nil {
+ return err
}
+ defer closeSource()
dataDir := config.DataDir()
if opts.Chat == nil {
remote := &collectorChat{dataDir: dataDir}
defer remote.Close()
opts.Chat = remote
}
- s := &shell{opts: opts, goos: runtime.GOOS, dataDir: dataDir, cfg: opts.Config, relaunchApp: relaunchApp}
+ s := &shell{opts: opts, source: source, goos: runtime.GOOS, dataDir: dataDir, cfg: opts.Config, relaunchApp: relaunchApp}
s.host = &windowHost{create: s.newWindow}
webOpts := opts.Web
webOpts.Chat, webOpts.ChatEphemeral = opts.Chat, false
@@ -105,7 +115,7 @@ func Run(opts Options) error {
webOpts.OpenExternal = func(url string) { _ = mygo.Shell.OpenExternal(url) }
// The page follows the app's appearance, which Settings changes live.
webOpts.Theme = "system"
- server, err := web.New(opts.Source, webOpts)
+ server, err := web.New(source, webOpts)
if err != nil {
return err
}
@@ -124,6 +134,9 @@ func Run(opts Options) error {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
s.ctx = ctx
+ if s.loginEnv != nil {
+ go s.loginEnv.refresh(ctx)
+ }
go server.Watch(ctx)
mygo.Protocol.Handle("mygo", server)
mygo.Theme.SetSource(themeSource(opts.Config.UI.Theme))
@@ -163,6 +176,22 @@ func Run(opts Options) error {
return err
}
+// openAfterLock takes the single-instance lock and only then opens the
+// source. It reports false, opening nothing, when another instance runs.
+func openAfterLock(lock func() bool, open func() (web.Source, func(), error)) (web.Source, func(), bool, error) {
+ if !lock() {
+ return nil, nil, false, nil
+ }
+ if open == nil {
+ return nil, nil, true, errors.New("desktop: no data source")
+ }
+ source, closeSource, err := open()
+ if err != nil {
+ return nil, nil, true, err
+ }
+ return source, closeSource, true, nil
+}
+
// prepareSupervisor fills the supervisor's production defaults and wires its
// reports to the shell. It returns the collector executable.
func (s *shell) prepareSupervisor(dataDir string) (string, error) {
@@ -178,10 +207,11 @@ func (s *shell) prepareSupervisor(dataDir string) (string, error) {
sup.Executable = exe
}
if sup.Start == nil {
+ s.loginEnv = newLoginEnv(dataDir, LoginEnvironment)
sup.Start = func(ctx context.Context) (Child, error) {
// Without the login environment the collector still runs, with
// the app's own; the status says what is missing.
- env, err := LoadEnvironment(ctx, dataDir)
+ env, err := s.loginEnv.get(ctx)
s.mu.Lock()
s.envErr = err
s.mu.Unlock()
@@ -280,24 +310,31 @@ func (s *shell) finish() {
}
// relaunch restarts the app on the upgraded executable, after its collector
-// stopped so the new one starts from the new binary. When the quit is
-// cancelled the app keeps running, and collecting.
-func (s *shell) relaunch() {
+// stopped so the new one starts from the new binary, and reports whether the
+// quit went ahead. When it is cancelled the app keeps running without a
+// collector: one started now would be the upgraded binary's, whose store the
+// old app may not read. The tray asks for a restart, and the upgrade watch
+// asks again on its next check.
+func (s *shell) relaunch() bool {
slog.Info("executable upgraded; relaunching", "event", "app_relaunch")
s.stopCollector()
// The relaunched process starts as the app again, whatever launched this one.
previous, set := os.LookupEnv("DRIGA_APP")
_ = os.Setenv("DRIGA_APP", "1")
if s.relaunchApp() {
- return
+ return true
}
- slog.Info("relaunch cancelled; collecting again", "event", "app_relaunch_cancelled")
+ slog.Info("relaunch cancelled; restart Driga to finish updating", "event", "app_relaunch_cancelled")
if set {
_ = os.Setenv("DRIGA_APP", previous)
} else {
_ = os.Unsetenv("DRIGA_APP")
}
- s.startCollector(nil)
+ s.mu.Lock()
+ s.restartPending = true
+ s.mu.Unlock()
+ s.updateTray()
+ return false
}
// relaunchApp asks the toolkit to relaunch and reports whether the quit went
@@ -327,6 +364,8 @@ func (s *shell) trayStatus() Status {
defer s.mu.Unlock()
st := s.status
switch {
+ case s.restartPending:
+ st = Status{RestartPending: true}
case st.Err != nil:
case s.serviceErr != nil:
// The error itself is in the log; the line has room for the fix.
@@ -343,9 +382,9 @@ func (s *shell) watchNeeds(ctx context.Context) {
ticker := time.NewTicker(needsPoll)
defer ticker.Stop()
for {
- version, err := s.opts.Source.DataVersion(ctx)
+ version, err := s.source.DataVersion(ctx)
if err == nil && version != last {
- if snap, err := s.opts.Source.LoadScope(ctx, store.Scope{}); err == nil {
+ if snap, err := s.source.LoadScope(ctx, store.Scope{}); err == nil {
last = version
observed := snap.ObservedAt
if observed.IsZero() {
@@ -374,7 +413,7 @@ func (s *shell) watchNeeds(ctx context.Context) {
func (s *shell) inboxRows() []view.InboxRow {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
- snap, err := s.opts.Source.LoadScope(ctx, store.Scope{})
+ snap, err := s.source.LoadScope(ctx, store.Scope{})
if err != nil {
slog.Warn("inbox unavailable for a notification click", "event", "notification_click_failed", "err", err.Error())
return nil
diff --git a/internal/desktop/app_test.go b/internal/desktop/app_test.go
index 7f626e7b..bde8ee55 100644
--- a/internal/desktop/app_test.go
+++ b/internal/desktop/app_test.go
@@ -9,6 +9,8 @@ import (
"sync"
"testing"
"time"
+
+ "github.com/labstack/driga/internal/web"
)
// A service the app could not remove keeps collecting, and the tray says how
@@ -107,7 +109,10 @@ func (f *fakeCollector) waitRunning(t *testing.T, runs int) {
t.Fatalf("collector runs %d, running %d; want %d, 1", r, n, runs)
}
-func TestRelaunchCancelledKeepsCollecting(t *testing.T) {
+// A cancelled relaunch leaves the collector stopped: starting it again would
+// run the upgraded binary's collector under the old app, which may then read
+// a store the new one migrated. The tray says how to finish.
+func TestRelaunchCancelledWaitsForRestart(t *testing.T) {
os.Unsetenv("DRIGA_APP")
t.Cleanup(func() { os.Unsetenv("DRIGA_APP") })
collector := &fakeCollector{}
@@ -124,17 +129,47 @@ func TestRelaunchCancelledKeepsCollecting(t *testing.T) {
s.ctx = ctx
s.startCollector(nil)
collector.waitRunning(t, 1)
- s.relaunch()
+ if s.relaunch() {
+ t.Fatal("a cancelled relaunch reported as done")
+ }
if during != "1" {
t.Fatalf("relaunched with DRIGA_APP=%q", during)
}
if value, set := os.LookupEnv("DRIGA_APP"); set {
t.Fatalf("DRIGA_APP=%q left set after the cancelled quit", value)
}
- collector.waitRunning(t, 2)
- s.stopCollector()
- if _, running := collector.state(); running != 0 {
- t.Fatal("collector still runs after stop")
+ time.Sleep(50 * time.Millisecond)
+ if runs, running := collector.state(); runs != 1 || running != 0 {
+ t.Fatalf("collector runs %d, running %d after a cancelled relaunch", runs, running)
+ }
+ s.status = Status{Err: errors.New("collector exited")}
+ if line := StatusLine(s.trayStatus()); line != "Restart Driga to finish updating" {
+ t.Fatalf("tray says %q", line)
+ }
+}
+
+// The watch keeps checking after a cancelled relaunch and asks again.
+func TestUpgradeWatchRetriesCancelledRelaunch(t *testing.T) {
+ stats := 0
+ w := &upgradeWatch{
+ exe: "/app/driga", version: "v1", interval: time.Millisecond,
+ // Replaced after the watch started.
+ stat: func(string) (time.Time, error) { stats++; return time.Unix(int64(min(stats, 2)), 0), nil },
+ read: func(context.Context, string) (string, error) { return "v2", nil },
+ }
+ calls := 0
+ done := make(chan struct{})
+ go func() {
+ defer close(done)
+ w.run(context.Background(), func() bool { calls++; return calls == 3 })
+ }()
+ select {
+ case <-done:
+ case <-time.After(5 * time.Second):
+ t.Fatal("watch stopped asking")
+ }
+ if calls != 3 {
+ t.Fatalf("relaunch asked %d times, want 3", calls)
}
}
@@ -173,3 +208,16 @@ func TestNoCollectorAfterQuit(t *testing.T) {
t.Fatalf("collector runs %d, running %d after quit", runs, running)
}
}
+
+// A second launch hands over to the running app before it opens the store,
+// which the running app's collector may be migrating.
+func TestSecondInstanceDoesNotOpenSource(t *testing.T) {
+ opened := 0
+ open := func() (web.Source, func(), error) { opened++; return nil, func() {}, nil }
+ if _, _, first, err := openAfterLock(func() bool { return false }, open); first || err != nil || opened != 0 {
+ t.Fatalf("second instance: first %v, err %v, opened %d", first, err, opened)
+ }
+ if _, closeSource, first, err := openAfterLock(func() bool { return true }, open); !first || err != nil || opened != 1 || closeSource == nil {
+ t.Fatalf("first instance: first %v, err %v, opened %d", first, err, opened)
+ }
+}
diff --git a/internal/desktop/assets/tray-ink-dot.png b/internal/desktop/assets/tray-ink-dot.png
index 2a7aa9e5..38219836 100644
Binary files a/internal/desktop/assets/tray-ink-dot.png and b/internal/desktop/assets/tray-ink-dot.png differ
diff --git a/internal/desktop/assets/tray-template-dot.png b/internal/desktop/assets/tray-template-dot.png
new file mode 100644
index 00000000..801156aa
Binary files /dev/null and b/internal/desktop/assets/tray-template-dot.png differ
diff --git a/internal/desktop/assets/tray-white-dot.png b/internal/desktop/assets/tray-white-dot.png
index 9811bad8..bdb9cfb6 100644
Binary files a/internal/desktop/assets/tray-white-dot.png and b/internal/desktop/assets/tray-white-dot.png differ
diff --git a/internal/desktop/child_test.go b/internal/desktop/child_test.go
index c73e973c..98156748 100644
--- a/internal/desktop/child_test.go
+++ b/internal/desktop/child_test.go
@@ -13,7 +13,8 @@ import (
"github.com/labstack/driga/internal/attention"
)
-const helperEnv = "DRIGA_DESKTOP_TEST_COLLECTOR"
+// Not a DRIGA_* name: the child takes those from the app only.
+const helperEnv = "DESKTOP_TEST_COLLECTOR"
// TestMain lets the test binary stand in for `driga collect`: it reports its
// arguments and environment as a notification, then runs until its parent
diff --git a/internal/desktop/count.go b/internal/desktop/count.go
index abe9ce07..7709d130 100644
--- a/internal/desktop/count.go
+++ b/internal/desktop/count.go
@@ -1,42 +1,31 @@
package desktop
-import (
- "embed"
- "strconv"
-)
+import "embed"
//go:embed assets/*.png
var assets embed.FS
-// CountLabel is the needs-you count as the tray title shows it.
-func CountLabel(n int) string {
- switch {
- case n <= 0:
- return ""
- case n > 9:
- return "9+"
- }
- return strconv.Itoa(n)
-}
-
// look is how the tray icon shows the count.
type look struct {
- Title, ToolTip string
- Icon string
- Template bool
+ ToolTip string
+ Icon string
+ Template bool
}
-// trayLook shows the count as the macOS menu bar title. Notification areas
-// have no title, so there the icon gains a dot and the tooltip says the count.
-// Their icons are drawn in color, light on a dark bar; Linux panels report no
-// color and are usually dark.
+// trayLook shows an icon only, never text: the icon gains a dot while
+// anything needs you, and the tooltip and menu say how many. The macOS menu
+// bar takes a template that it tints; notification areas draw icons in color,
+// light on a dark bar, and Linux panels report no color and are usually dark.
func trayLook(goos string, n int, dark bool) look {
l := look{ToolTip: "Driga"}
if n > 0 {
l.ToolTip += " · " + needLabel(n)
}
if goos == "darwin" {
- l.Title, l.Icon, l.Template = CountLabel(n), "tray-template.png", true
+ l.Icon, l.Template = "tray-template.png", true
+ if n > 0 {
+ l.Icon = "tray-template-dot.png"
+ }
return l
}
tone := "ink"
diff --git a/internal/desktop/count_test.go b/internal/desktop/count_test.go
index fb9f4c97..aed2395a 100644
--- a/internal/desktop/count_test.go
+++ b/internal/desktop/count_test.go
@@ -2,34 +2,26 @@ package desktop
import "testing"
-func TestCountLabel(t *testing.T) {
- for n, want := range map[int]string{-1: "", 0: "", 1: "1", 9: "9", 10: "9+", 120: "9+"} {
- if got := CountLabel(n); got != want {
- t.Errorf("CountLabel(%d) = %q; want %q", n, got, want)
- }
- }
-}
-
func TestTrayLook(t *testing.T) {
for _, tc := range []struct {
- goos string
- n int
- dark bool
- title, tooltip string
- icon string
- template bool
+ goos string
+ n int
+ dark bool
+ tooltip string
+ icon string
+ template bool
}{
- {"darwin", 0, false, "", "Driga", "tray-template.png", true},
- {"darwin", 3, true, "3", "Driga · 3 need you", "tray-template.png", true},
- {"darwin", 12, false, "9+", "Driga · 12 need you", "tray-template.png", true},
- {"windows", 0, true, "", "Driga", "tray-white.png", false},
- {"windows", 2, true, "", "Driga · 2 need you", "tray-white-dot.png", false},
- {"windows", 1, false, "", "Driga · 1 needs you", "tray-ink-dot.png", false},
- {"linux", 0, false, "", "Driga", "tray-white.png", false},
- {"linux", 4, false, "", "Driga · 4 need you", "tray-white-dot.png", false},
+ {"darwin", 0, false, "Driga", "tray-template.png", true},
+ {"darwin", 3, true, "Driga · 3 need you", "tray-template-dot.png", true},
+ {"darwin", 12, false, "Driga · 12 need you", "tray-template-dot.png", true},
+ {"windows", 0, true, "Driga", "tray-white.png", false},
+ {"windows", 2, true, "Driga · 2 need you", "tray-white-dot.png", false},
+ {"windows", 1, false, "Driga · 1 needs you", "tray-ink-dot.png", false},
+ {"linux", 0, false, "Driga", "tray-white.png", false},
+ {"linux", 4, false, "Driga · 4 need you", "tray-white-dot.png", false},
} {
look := trayLook(tc.goos, tc.n, tc.dark)
- if look.Title != tc.title || look.ToolTip != tc.tooltip || look.Icon != tc.icon || look.Template != tc.template {
+ if look.ToolTip != tc.tooltip || look.Icon != tc.icon || look.Template != tc.template {
t.Errorf("trayLook(%s, %d, %v) = %+v", tc.goos, tc.n, tc.dark, look)
}
if _, err := assets.ReadFile("assets/" + look.Icon); err != nil {
diff --git a/internal/desktop/env.go b/internal/desktop/env.go
index 69209be7..d3410398 100644
--- a/internal/desktop/env.go
+++ b/internal/desktop/env.go
@@ -7,12 +7,14 @@ import (
"encoding/json"
"errors"
"fmt"
+ "log/slog"
"os"
"os/exec"
"path/filepath"
"runtime"
"slices"
"strings"
+ "sync"
"time"
"github.com/labstack/driga/internal/secfile"
@@ -61,42 +63,100 @@ func LoginEnvironment(ctx context.Context) (map[string]string, error) {
return env, scanner.Err()
}
-// LoadEnvironment returns the login environment stored in dataDir, capturing
-// and storing it on first use. Running the login shell is slow and can show
-// prompts from the user's profile, so it happens once.
-func LoadEnvironment(ctx context.Context, dataDir string) (map[string]string, error) {
- path := filepath.Join(dataDir, "app-env.json")
- if b, err := os.ReadFile(path); err == nil {
- env := map[string]string{}
- if err := json.Unmarshal(b, &env); err != nil {
- return nil, fmt.Errorf("read %s: %w", path, err)
+// loginEnv is the login environment collector starts use. Each launch reads
+// the login shell again in the background, so a PATH changed since the last
+// launch takes effect; until that capture ends, the one an earlier launch
+// stored in the data directory serves.
+type loginEnv struct {
+ path string
+ capture func(context.Context) (map[string]string, error)
+ // done closes once this launch's capture ended.
+ done chan struct{}
+ mu sync.Mutex
+ fresh map[string]string
+ err error
+}
+
+func newLoginEnv(dataDir string, capture func(context.Context) (map[string]string, error)) *loginEnv {
+ return &loginEnv{path: filepath.Join(dataDir, "app-env.json"), capture: capture, done: make(chan struct{})}
+}
+
+// refresh captures the login environment and stores it for the next launch.
+// It runs once per launch.
+func (e *loginEnv) refresh(ctx context.Context) {
+ defer close(e.done)
+ env, err := e.capture(ctx)
+ if err == nil {
+ if werr := e.store(env); werr != nil {
+ slog.Warn("could not store the login environment", "event", "login_env_store_failed", "err", werr.Error())
}
- return env, nil
- } else if !errors.Is(err, os.ErrNotExist) {
- return nil, err
- }
- env, err := LoginEnvironment(ctx)
- if err != nil {
- return nil, err
}
+ e.mu.Lock()
+ e.fresh, e.err = env, err
+ e.mu.Unlock()
+}
+
+func (e *loginEnv) store(env map[string]string) error {
b, err := json.Marshal(env)
if err != nil {
+ return err
+ }
+ if err := secfile.MkdirPrivate(filepath.Dir(e.path)); err != nil {
+ return err
+ }
+ return secfile.WritePrivate(e.path, b)
+}
+
+// get returns this launch's capture once it ended, else the stored
+// environment. With neither, as on the first launch, it waits for the capture.
+func (e *loginEnv) get(ctx context.Context) (map[string]string, error) {
+ select {
+ case <-e.done:
+ e.mu.Lock()
+ fresh, err := e.fresh, e.err
+ e.mu.Unlock()
+ if err == nil {
+ return fresh, nil
+ }
+ if stored, serr := e.stored(); serr == nil {
+ return stored, nil
+ }
return nil, err
+ default:
}
- if err := secfile.MkdirPrivate(dataDir); err != nil {
+ stored, err := e.stored()
+ if err == nil {
+ return stored, nil
+ }
+ if !errors.Is(err, os.ErrNotExist) {
return nil, err
}
- if err := secfile.WritePrivate(path, b); err != nil {
+ select {
+ case <-e.done:
+ return e.get(ctx)
+ case <-ctx.Done():
+ return nil, ctx.Err()
+ }
+}
+
+func (e *loginEnv) stored() (map[string]string, error) {
+ b, err := os.ReadFile(e.path)
+ if err != nil {
return nil, err
}
+ env := map[string]string{}
+ if err := json.Unmarshal(b, &env); err != nil {
+ return nil, fmt.Errorf("read %s: %w", e.path, err)
+ }
return env, nil
}
// childEnvironment merges the login environment over a minimal base taken
// from the app's own environment. Windows children inherit the session
-// environment whole, as any program started from the shell does. The app's
-// own DRIGA_* variables win over the login ones: they name the data and
-// config the app shows, which its collector must write.
+// environment whole, as any program started from the shell does. The child's
+// DRIGA_* variables are exactly the app's own, without DRIGA_APP: they name
+// the data, config and settings the app shows, which its collector must use,
+// so a login shell's never reach it.
func childEnvironment(login map[string]string) []string {
env := map[string]string{}
if runtime.GOOS == "windows" {
@@ -115,8 +175,13 @@ func childEnvironment(login map[string]string) []string {
for key, value := range login {
env[key] = value
}
+ for key := range env {
+ if strings.HasPrefix(key, "DRIGA_") {
+ delete(env, key)
+ }
+ }
for _, entry := range os.Environ() {
- if key, value, ok := strings.Cut(entry, "="); ok && strings.HasPrefix(key, "DRIGA_") {
+ if key, value, ok := strings.Cut(entry, "="); ok && strings.HasPrefix(key, "DRIGA_") && key != "DRIGA_APP" {
env[key] = value
}
}
diff --git a/internal/desktop/env_test.go b/internal/desktop/env_test.go
index e1165afc..3a4b788e 100644
--- a/internal/desktop/env_test.go
+++ b/internal/desktop/env_test.go
@@ -2,12 +2,15 @@ package desktop
import (
"context"
+ "encoding/json"
"os"
"path/filepath"
"reflect"
"runtime"
"slices"
+ "strings"
"testing"
+ "time"
)
// fakeShell writes a login shell under the isolated HOME that prints a fixed
@@ -63,33 +66,94 @@ func TestLoginEnvironmentFailingShell(t *testing.T) {
}
}
-func TestLoadEnvironmentCapturesOnce(t *testing.T) {
- dir := fakeShell(t, `echo run >> "$(dirname "$0")/runs"
-echo "PATH=/login/bin"
+// gatedShell is a login shell that prints PATH=path once the returned gate
+// is opened.
+func gatedShell(t *testing.T, path string) (open func()) {
+ t.Helper()
+ dir := fakeShell(t, `while [ ! -f "$(dirname "$0")/gate" ]; do sleep 0.02; done
+echo run >> "$(dirname "$0")/runs"
+echo "PATH=`+path+`"
`)
- data := t.TempDir()
- for range 2 {
- got, err := LoadEnvironment(context.Background(), data)
- if err != nil {
+ return func() {
+ if err := os.WriteFile(filepath.Join(dir, "gate"), nil, 0o600); err != nil {
t.Fatal(err)
}
- if !reflect.DeepEqual(got, map[string]string{"PATH": "/login/bin"}) {
- t.Fatalf("environment = %v", got)
- }
}
- runs, err := os.ReadFile(filepath.Join(dir, "runs"))
+}
+
+func writeStoredEnv(t *testing.T, dataDir string, env map[string]string) {
+ t.Helper()
+ b, err := json.Marshal(env)
if err != nil {
t.Fatal(err)
}
- if string(runs) != "run\n" {
- t.Fatalf("shell ran %q, want once", runs)
+ if err := os.WriteFile(filepath.Join(dataDir, "app-env.json"), b, 0o600); err != nil {
+ t.Fatal(err)
+ }
+}
+
+// Every launch reads the login shell again, so a PATH changed since an
+// earlier launch reaches the collectors started after the capture ends.
+// Until then the stored environment serves.
+func TestLoginEnvRefreshesEveryLaunch(t *testing.T) {
+ open := gatedShell(t, "/new/bin")
+ data := t.TempDir()
+ writeStoredEnv(t, data, map[string]string{"PATH": "/old/bin"})
+ env := newLoginEnv(data, LoginEnvironment)
+ go env.refresh(context.Background())
+ got, err := env.get(context.Background())
+ if err != nil || !reflect.DeepEqual(got, map[string]string{"PATH": "/old/bin"}) {
+ t.Fatalf("before the capture: %v, %v", got, err)
+ }
+ open()
+ <-env.done
+ got, err = env.get(context.Background())
+ if err != nil || !reflect.DeepEqual(got, map[string]string{"PATH": "/new/bin"}) {
+ t.Fatalf("after the capture: %v, %v", got, err)
+ }
+ stored, err := os.ReadFile(filepath.Join(data, "app-env.json"))
+ if err != nil || string(stored) != `{"PATH":"/new/bin"}` {
+ t.Fatalf("stored %s, %v", stored, err)
}
info, err := os.Stat(filepath.Join(data, "app-env.json"))
- if err != nil {
- t.Fatal(err)
+ if err != nil || info.Mode().Perm() != 0o600 {
+ t.Fatalf("app-env.json: %v, %v", info, err)
+ }
+}
+
+// The first launch has nothing stored and waits for the capture.
+func TestLoginEnvFirstLaunchWaits(t *testing.T) {
+ open := gatedShell(t, "/login/bin")
+ env := newLoginEnv(t.TempDir(), LoginEnvironment)
+ go env.refresh(context.Background())
+ done := make(chan map[string]string, 1)
+ go func() {
+ got, _ := env.get(context.Background())
+ done <- got
+ }()
+ select {
+ case got := <-done:
+ t.Fatalf("returned %v before the capture", got)
+ case <-time.After(50 * time.Millisecond):
+ }
+ open()
+ if got := <-done; !reflect.DeepEqual(got, map[string]string{"PATH": "/login/bin"}) {
+ t.Fatalf("environment = %v", got)
+ }
+}
+
+// A failed capture keeps the stored environment; with none, it is the error.
+func TestLoginEnvFailedCapture(t *testing.T) {
+ fakeShell(t, "exit 3\n")
+ data := t.TempDir()
+ env := newLoginEnv(data, LoginEnvironment)
+ env.refresh(context.Background())
+ if _, err := env.get(context.Background()); err == nil {
+ t.Fatal("failed capture without a stored environment accepted")
}
- if info.Mode().Perm() != 0o600 {
- t.Fatalf("app-env.json mode = %v, want 0600", info.Mode().Perm())
+ writeStoredEnv(t, data, map[string]string{"PATH": "/old/bin"})
+ if got, err := env.get(context.Background()); err != nil || got["PATH"] != "/old/bin" {
+ t.Fatalf("stored environment: %v, %v", got, err)
}
}
@@ -117,19 +181,34 @@ func TestChildEnvironmentMergesLoginOverBase(t *testing.T) {
}
// The app's own DRIGA_* variables say where it reads and writes, so its
-// collector uses them over the login shell's.
-func TestChildEnvironmentKeepsAppDrigaValues(t *testing.T) {
+// collector gets exactly those: a login shell's DRIGA_* would point the
+// collector at data or settings the app does not show.
+func TestChildEnvironmentDrigaFromAppOnly(t *testing.T) {
+ for _, entry := range os.Environ() {
+ if key, _, _ := strings.Cut(entry, "="); strings.HasPrefix(key, "DRIGA_") {
+ t.Setenv(key, "")
+ os.Unsetenv(key) // restored by t.Setenv
+ }
+ }
t.Setenv("DRIGA_DATA_DIR", "/app/data")
t.Setenv("DRIGA_CONFIG", "/app/config.toml")
- t.Setenv("DRIGA_SPOOL_DIR", "")
- os.Unsetenv("DRIGA_SPOOL_DIR") // restored by t.Setenv
- env := childEnvironment(map[string]string{"DRIGA_DATA_DIR": "/login/data", "DRIGA_SPOOL_DIR": "/login/spool"})
- for _, want := range []string{"DRIGA_DATA_DIR=/app/data", "DRIGA_CONFIG=/app/config.toml", "DRIGA_SPOOL_DIR=/login/spool"} {
+ t.Setenv("DRIGA_APP", "1")
+ env := childEnvironment(map[string]string{
+ "DRIGA_DATA_DIR": "/login/data", "DRIGA_SPOOL_DIR": "/login/spool", "DRIGA_UI_THEME": "dark",
+ "PATH": "/login/bin", "TMUX_TMPDIR": "/tmp/tmux", "XDG_CONFIG_HOME": "/login/xdg",
+ })
+ driga := []string{}
+ for _, entry := range env {
+ if strings.HasPrefix(entry, "DRIGA_") {
+ driga = append(driga, entry)
+ }
+ }
+ if want := []string{"DRIGA_CONFIG=/app/config.toml", "DRIGA_DATA_DIR=/app/data"}; !slices.Equal(driga, want) {
+ t.Fatalf("DRIGA_* = %v, want %v", driga, want)
+ }
+ for _, want := range []string{"PATH=/login/bin", "TMUX_TMPDIR=/tmp/tmux", "XDG_CONFIG_HOME=/login/xdg"} {
if !slices.Contains(env, want) {
t.Fatalf("environment %v lacks %s", env, want)
}
}
- if slices.Contains(env, "DRIGA_DATA_DIR=/login/data") {
- t.Fatalf("login DRIGA_DATA_DIR kept: %v", env)
- }
}
diff --git a/internal/desktop/menu.go b/internal/desktop/menu.go
index 5acc079d..2622e831 100644
--- a/internal/desktop/menu.go
+++ b/internal/desktop/menu.go
@@ -131,6 +131,8 @@ func needLabel(n int) string {
// StatusLine says who collects, or why nothing does.
func StatusLine(st Status) string {
switch {
+ case st.RestartPending:
+ return "Restart Driga to finish updating"
case st.Collecting && st.Err != nil:
return clip("Collecting · "+st.Err.Error(), 80)
case st.Collecting:
diff --git a/internal/desktop/supervisor.go b/internal/desktop/supervisor.go
index 66964e77..4e68c16f 100644
--- a/internal/desktop/supervisor.go
+++ b/internal/desktop/supervisor.go
@@ -28,10 +28,14 @@ const (
// orphanWait bounds how long a terminated orphan may take to free the lock.
orphanWait = 10 * time.Second
orphanPoll = 100 * time.Millisecond
- // readyTimeout bounds how long a new child may take to answer on the chat socket.
+ // readyTimeout bounds one check that a new child answers on the chat socket.
readyTimeout = 10 * time.Second
)
+// readyBackoff is the wait before each further check of a child that does
+// not answer yet. The last step repeats.
+var readyBackoff = []time.Duration{time.Second, 2 * time.Second, 5 * time.Second, 10 * time.Second, 30 * time.Second}
+
// Child is a running collector process.
type Child interface {
// Wait blocks until the child exits. It is called once.
@@ -50,6 +54,9 @@ type Status struct {
// Owner is the other process holding the collector lock, if any.
Owner collector.Owner
Err error
+ // RestartPending is the app's: an upgrade waits for it to restart, and
+ // no collector runs until then.
+ RestartPending bool
}
// Supervisor keeps one collector running for the app. When the lock is free it
@@ -188,24 +195,35 @@ func (s *Supervisor) supervise(ctx context.Context) error {
readyCtx, cancelReady := context.WithCancel(ctx)
defer cancelReady()
ready := make(chan error, 1)
- go func() {
- if s.Ready == nil {
- ready <- nil
- return
- }
- ready <- s.Ready(readyCtx)
- }()
+ check := func() {
+ go func() {
+ if s.Ready == nil {
+ ready <- nil
+ return
+ }
+ ready <- s.Ready(readyCtx)
+ }()
+ }
+ check()
+ // A child can take long to serve chat, as while it migrates the store:
+ // it is asked again with backoff for as long as it runs.
+ var retry <-chan time.Time
+ attempt := 0
for {
select {
case err := <-ready:
- ready = nil
if err != nil {
// Not collecting as far as the app is concerned: it opens
// chat only while Collecting is set.
s.report(Status{Err: fmt.Errorf("collector not answering: %w", err)})
+ retry = s.clock.After(readyBackoff[min(attempt, len(readyBackoff)-1)])
+ attempt++
continue
}
s.report(Status{Collecting: true})
+ case <-retry:
+ retry = nil
+ check()
case err := <-exited:
<-drained
if ctx.Err() != nil {
@@ -254,7 +272,8 @@ func sameStatus(a, b Status) bool {
}
// WaitChat returns once the collector's chat socket in dataDir accepts a
-// connection, or after ten seconds with the last dial error.
+// connection, or after ten seconds with the last dial error. The supervisor
+// calls it again while the collector runs.
func WaitChat(ctx context.Context, dataDir string) error {
ctx, cancel := context.WithTimeout(ctx, readyTimeout)
defer cancel()
diff --git a/internal/desktop/supervisor_test.go b/internal/desktop/supervisor_test.go
index 9efabb17..ca59c88a 100644
--- a/internal/desktop/supervisor_test.go
+++ b/internal/desktop/supervisor_test.go
@@ -615,3 +615,35 @@ func TestSupervisorReadyFailureIsNotCollecting(t *testing.T) {
t.Fatalf("statuses = %+v, want not collecting with an error", got)
}
}
+
+// A child slow to serve chat, such as one migrating a large store, is asked
+// again with backoff while it runs and counts as collecting once it answers.
+func TestSupervisorRetriesReadyWhileChildRuns(t *testing.T) {
+ clock := &fakeClock{}
+ statuses := &statusLog{}
+ s := newSupervisor(clock, statuses)
+ var mu sync.Mutex
+ checks := 0
+ s.Ready = func(context.Context) error {
+ mu.Lock()
+ defer mu.Unlock()
+ checks++
+ if checks <= 3 {
+ return errors.New("connection refused")
+ }
+ return nil
+ }
+ s.Start = func(context.Context) (Child, error) { return newFakeChild(), nil }
+ ctx, cancel := context.WithCancel(context.Background())
+ done := runSupervisor(t, s, ctx)
+ waitCollecting(t, statuses)
+ cancel()
+ wait(t, done)
+ if want := []time.Duration{time.Second, 2 * time.Second, 5 * time.Second}; !reflect.DeepEqual(clock.waits(), want) {
+ t.Fatalf("ready retries waited %v, want %v", clock.waits(), want)
+ }
+ got := statuses.all()
+ if len(got) != 2 || got[0].Collecting || got[0].Err == nil || !got[1].Collecting || got[1].Err != nil {
+ t.Fatalf("statuses = %+v, want not answering, then collecting", got)
+ }
+}
diff --git a/internal/desktop/tray.go b/internal/desktop/tray.go
index fe1f978d..1d2e0b07 100644
--- a/internal/desktop/tray.go
+++ b/internal/desktop/tray.go
@@ -51,6 +51,9 @@ func (s *shell) updateTray() { s.redrawTray(false) }
// goroutines; drawing on the main thread keeps an older menu from landing
// after a newer one.
func (s *shell) redrawTray(force bool) {
+ if !s.trayCreated() {
+ return
+ }
mygo.RunOnMain(func() { s.drawTray(force) })
}
@@ -75,9 +78,6 @@ func (s *shell) drawTray(force bool) {
if icon, err := assets.ReadFile("assets/" + l.Icon); err == nil {
_ = tray.SetIcon(icon, l.Template)
}
- if s.goos == "darwin" {
- tray.SetTitle(l.Title)
- }
tray.SetToolTip(l.ToolTip)
tray.SetMenu(s.menu(model))
}
diff --git a/internal/desktop/upgrade.go b/internal/desktop/upgrade.go
index 68ac2ef7..30bb8dcb 100644
--- a/internal/desktop/upgrade.go
+++ b/internal/desktop/upgrade.go
@@ -17,9 +17,11 @@ const upgradeInterval = time.Minute
type upgradeWatch struct {
exe string
version string
- stat func(path string) (time.Time, error)
- read func(ctx context.Context, exe string) (string, error)
- stamp time.Time
+ // interval defaults to upgradeInterval.
+ interval time.Duration
+ stat func(path string) (time.Time, error)
+ read func(ctx context.Context, exe string) (string, error)
+ stamp time.Time
}
func newUpgradeWatch(exe, version string) *upgradeWatch {
@@ -48,18 +50,23 @@ func (w *upgradeWatch) changed(ctx context.Context) bool {
return false
}
-// run checks every minute and calls upgraded once, on a new version.
-func (w *upgradeWatch) run(ctx context.Context, upgraded func()) {
+// run checks every minute and calls upgraded on a new version until it
+// reports that the app restarts. A refused restart is asked again on the
+// next check.
+func (w *upgradeWatch) run(ctx context.Context, upgraded func() bool) {
w.start()
- ticker := time.NewTicker(upgradeInterval)
+ interval := w.interval
+ if interval <= 0 {
+ interval = upgradeInterval
+ }
+ ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
- if w.changed(ctx) {
- upgraded()
+ if w.changed(ctx) && upgraded() {
return
}
}
diff --git a/internal/web/chat.go b/internal/web/chat.go
index 8960d08f..55d3c208 100644
--- a/internal/web/chat.go
+++ b/internal/web/chat.go
@@ -172,7 +172,9 @@ func (s *Server) chatWait(r *http.Request) (chat.State, int, error) {
} else {
waitCtx, cancel := context.WithTimeout(ctx, s.pollTimeout)
state, err = s.opts.Chat.Wait(waitCtx, id, after)
- timedOut := waitCtx.Err() != nil && ctx.Err() == nil
+ // The client may give up first, on a deadline of its own: either
+ // way nothing changed, and the poll answers with the current state.
+ timedOut := ctx.Err() == nil && (waitCtx.Err() != nil || errors.Is(err, context.DeadlineExceeded))
cancel()
if err != nil && timedOut {
state, err = s.opts.Chat.Read(ctx, id, after)
diff --git a/internal/web/chat_wait_test.go b/internal/web/chat_wait_test.go
index 008e0c89..d9c72dac 100644
--- a/internal/web/chat_wait_test.go
+++ b/internal/web/chat_wait_test.go
@@ -123,3 +123,26 @@ func TestWebChatSurvivesCollectorBrokerRestart(t *testing.T) {
t.Fatal(w.Code, w.Body)
}
}
+
+// expiringChat gives up on Wait with its own deadline, as the collector's
+// chat client does when its request timeout ends before the page's poll.
+type expiringChat struct{ chat.Client }
+
+func (c expiringChat) Wait(ctx context.Context, id string, after uint64) (chat.State, error) {
+ time.Sleep(20 * time.Millisecond)
+ return chat.State{}, fmt.Errorf("wait: %w", context.DeadlineExceeded)
+}
+
+func TestChatWaitClientDeadlineIsNoChange(t *testing.T) {
+ s, b, id := chatFixture(t)
+ s.opts.Chat = expiringChat{Client: b}
+ s.pollTimeout = time.Hour
+ if err := b.SetMode(context.Background(), id, chat.Auto); err != nil {
+ t.Fatal(err)
+ }
+ initial := waitChat(t, s, id, 0)
+ got := waitChat(t, s, id, initial.Cursor)
+ if got.Cursor != initial.Cursor || got.Reset || len(got.Events) != 0 {
+ t.Fatalf("a client deadline must answer the current state: %+v", got.State)
+ }
+}
diff --git a/internal/web/server.go b/internal/web/server.go
index bef41580..7e524d9e 100644
--- a/internal/web/server.go
+++ b/internal/web/server.go
@@ -12,6 +12,7 @@ import (
"net/http"
"net/url"
"path"
+ "slices"
"strconv"
"strings"
"sync"
@@ -192,25 +193,75 @@ func (s *Server) newRoutes() *http.ServeMux {
return mux
}
-// tailMark is the newest tail row a page holds, in the tail's (time, key) order.
+// seenKeys bounds the tail keys a cursor remembers.
+const seenKeys = 64
+
+// shortKey is the part of a tail key a cursor keeps: tail keys are SHA-256
+// hex, and sixteen digits tell a page's rows apart.
+func shortKey(key string) string { return key[:min(len(key), 16)] }
+
+// tailMark is what a page holds of the tail: the newest row in the tail's
+// (time, key) order, and the keys of the newest rows at or before it. A row
+// at or after floor that the page has not seen is sent even when it is older
+// than the newest, as when a machine's events arrive late; older rows count
+// as seen, which keeps the remembered keys bounded.
type tailMark struct {
- at int64
- key string // Empty before the first row.
+ at int64
+ key string // Empty before the first row.
+ floor int64
+ seen []string // Short keys, oldest first.
}
-func (m tailMark) before(row view.TailEvent) bool {
+func (m tailMark) newer(row view.TailEvent) bool {
at := row.TS.UnixNano()
return m.key == "" || at > m.at || at == m.at && row.Key > m.key
}
-// The cursor is opaque to the page: refresh generation and tail mark.
+// unseen reports whether the page lacks row.
+func (m tailMark) unseen(row view.TailEvent) bool {
+ if m.newer(row) {
+ return true
+ }
+ return row.TS.UnixNano() >= m.floor && !slices.Contains(m.seen, shortKey(row.Key))
+}
+
+// covering returns the mark once the page holds tail up to newest: every row
+// of tail not newer than newest is then sent or deliberately skipped.
+func (m tailMark) covering(tail []view.TailEvent, newest tailMark) tailMark {
+ held := []view.TailEvent{}
+ for _, row := range tail {
+ if !newest.newer(row) {
+ held = append(held, row)
+ }
+ }
+ next := tailMark{at: newest.at, key: newest.key, floor: m.floor}
+ if len(held) > seenKeys {
+ cut := len(held) - seenKeys
+ floor := held[cut].TS.UnixNano()
+ if held[cut-1].TS.UnixNano() == floor {
+ // A forgotten row shares the oldest kept time: start after it.
+ floor++
+ }
+ next.floor = max(next.floor, floor)
+ held = held[cut:]
+ }
+ for _, row := range held {
+ if row.TS.UnixNano() >= next.floor {
+ next.seen = append(next.seen, shortKey(row.Key))
+ }
+ }
+ return next
+}
+
+// The cursor is opaque to the page: refresh generation and tail mark. The
+// key goes last, so any character in it survives.
func encodeCursor(generation uint64, mark tailMark) string {
- return fmt.Sprintf("%d.%d.%s", generation, mark.at, mark.key)
+ return fmt.Sprintf("%d.%d.%d.%s.%s", generation, mark.at, mark.floor, strings.Join(mark.seen, ","), mark.key)
}
func decodeCursor(raw string) (uint64, tailMark, error) {
- parts := strings.SplitN(raw, ".", 3)
- if len(parts) != 3 {
+ parts := strings.SplitN(raw, ".", 5)
+ if len(parts) != 5 {
return 0, tailMark{}, fmt.Errorf("invalid cursor")
}
generation, err := strconv.ParseUint(parts[0], 10, 64)
@@ -221,7 +272,18 @@ func decodeCursor(raw string) (uint64, tailMark, error) {
if err != nil {
return 0, tailMark{}, fmt.Errorf("invalid cursor")
}
- return generation, tailMark{at: at, key: parts[2]}, nil
+ floor, err := strconv.ParseInt(parts[2], 10, 64)
+ if err != nil {
+ return 0, tailMark{}, fmt.Errorf("invalid cursor")
+ }
+ mark := tailMark{at: at, key: parts[4], floor: floor}
+ if parts[3] != "" {
+ mark.seen = strings.Split(parts[3], ",")
+ if len(mark.seen) > seenKeys {
+ return 0, tailMark{}, fmt.Errorf("invalid cursor")
+ }
+ }
+ return generation, mark, nil
}
type update struct {
@@ -307,15 +369,16 @@ func (s *Server) tailSince(r *http.Request, mark tailMark) ([]view.TailEvent, ta
return []view.TailEvent{}, mark
} // Keep refreshes alive through a transient read failure.
rows := []view.TailEvent{}
- next := mark
+ newest := mark
for _, row := range page.Tail {
- if mark.before(row) {
+ if mark.unseen(row) {
rows = append(rows, row)
- if next.before(row) {
- next = tailMark{at: row.TS.UnixNano(), key: row.Key}
+ if newest.newer(row) {
+ newest = tailMark{at: row.TS.UnixNano(), key: row.Key}
}
}
}
+ next := mark.covering(page.Tail, newest)
// Flood policy: retain the newest twenty per answer; no unbounded page queue.
if len(rows) > 20 {
rows = rows[len(rows)-20:]
diff --git a/internal/web/static/app.css b/internal/web/static/app.css
index 69c19814..fef85933 100644
--- a/internal/web/static/app.css
+++ b/internal/web/static/app.css
@@ -81,7 +81,7 @@ button:hover {
text-overflow:ellipsis;
}
.seg.brand {
- background:#bd3927;
+ background:#d20f39;
color:#fff;
font-weight:700;
letter-spacing:.02em;
diff --git a/internal/web/static/brand/Driga.icns b/internal/web/static/brand/Driga.icns
index 457fdd43..ab54d409 100644
Binary files a/internal/web/static/brand/Driga.icns and b/internal/web/static/brand/Driga.icns differ
diff --git a/internal/web/static/brand/app-icon.svg b/internal/web/static/brand/app-icon.svg
new file mode 100644
index 00000000..17412676
--- /dev/null
+++ b/internal/web/static/brand/app-icon.svg
@@ -0,0 +1,13 @@
+
diff --git a/internal/web/static/brand/apple-touch-icon.png b/internal/web/static/brand/apple-touch-icon.png
index 9b208a53..59f9251f 100644
Binary files a/internal/web/static/brand/apple-touch-icon.png and b/internal/web/static/brand/apple-touch-icon.png differ
diff --git a/internal/web/static/brand/favicon-32.png b/internal/web/static/brand/favicon-32.png
index 72c5fe62..e185a1d8 100644
Binary files a/internal/web/static/brand/favicon-32.png and b/internal/web/static/brand/favicon-32.png differ
diff --git a/internal/web/static/brand/favicon.svg b/internal/web/static/brand/favicon.svg
index cd1f7cdd..d34d85b7 100644
--- a/internal/web/static/brand/favicon.svg
+++ b/internal/web/static/brand/favicon.svg
@@ -1,6 +1,6 @@