Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/brand.html
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
<style>
@font-face{font-family:Plex;src:url('../internal/web/static/fonts/ibm-plex-sans-latin-400-normal.woff2');font-weight:400;font-display:swap}
@font-face{font-family:Plex;src:url('../internal/web/static/fonts/ibm-plex-sans-latin-600-normal.woff2');font-weight:600;font-display:swap}
:root{--ink:#1b1e21;--muted:#62666b;--line:#dce0e2;--red:#bd3927}*{box-sizing:border-box}body{margin:0;background:#fafafa;color:var(--ink);font:16px/1.6 Plex,Arial,sans-serif}main,header,footer{width:min(1100px,calc(100% - 48px));margin:auto}header{display:flex;align-items:center;justify-content:space-between;padding:25px 0;border-bottom:1px solid var(--line)}header img{width:112px;height:40px}header span,.label{font:11px/1.5 ui-monospace,monospace;letter-spacing:.08em;text-transform:uppercase;color:var(--muted)}h1,h2,p{margin:0}h1{font-size:50px;line-height:1.1;font-weight:600;letter-spacing:-.05em;margin:18px 0}h2{font-size:22px;font-weight:600;letter-spacing:-.025em}a{color:inherit;text-underline-offset:4px}a:focus-visible{outline:3px solid var(--red);outline-offset:5px}.intro{padding:50px 0 35px}.intro p{max-width:580px;color:var(--muted)}.hero{display:flex;align-items:center;justify-content:center;background:#f1f2f3;border-radius:4px;min-height:320px;padding:35px}.hero img{width:min(560px,100%);height:auto}.variations{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:18px;margin:20px 0 45px}.variant{min-width:0}.plate{height:215px;display:flex;align-items:center;justify-content:center;background:var(--plate);border-radius:3px}.plate img{width:125px;height:125px}.variant p{display:flex;align-items:center;justify-content:space-between;gap:10px;font-size:12px;margin:12px 0 0}.variant p a{font-size:11px;color:var(--muted)}.section{padding:34px 0;border-top:1px solid var(--line)}.palette,.rules{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:30px;margin-top:23px}.swatch{display:flex;align-items:center;gap:13px;font-size:13px}.swatch i{display:block;width:42px;height:42px;background:var(--swatch);border:1px solid #0002;border-radius:3px}.swatch code{display:block;color:var(--muted);font-size:11px}.rules h3{font-size:14px;margin:0 0 9px}.rules p{font-size:13px;color:var(--muted)}.downloads{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:0 35px;margin-top:20px}.downloads a{display:flex;align-items:center;justify-content:space-between;gap:18px;padding:15px 0;border-bottom:1px solid var(--line);text-decoration:none;font-size:13px}.downloads a:hover{text-decoration:underline}.downloads span{font:10px ui-monospace,monospace;color:var(--muted);white-space:nowrap}.sizes{display:flex;gap:25px;align-items:flex-end;margin-top:25px}.sizes figure{margin:0;text-align:center}.sizes figcaption{font:10px ui-monospace,monospace;color:var(--muted);margin-top:10px}.sizes img{display:block}.app{background:var(--red);border-radius:13px;width:64px;height:64px;display:grid;place-items:center}.app img{width:54px;height:54px}footer{border-top:1px solid var(--line);padding:26px 0 40px;color:var(--muted);font-size:12px;display:flex;justify-content:space-between;gap:30px}@media(max-width:650px){main,header,footer{width:calc(100% - 32px)}header span{font-size:9px}h1{font-size:42px}.intro{padding-top:35px}.hero{min-height:220px;padding:20px}.variations{gap:10px}.plate{height:145px}.plate img{width:92px;height:92px}.variant p{flex-direction:column;align-items:flex-start;gap:4px}.palette,.rules{grid-template-columns:1fr;gap:22px}.downloads{grid-template-columns:1fr}.sizes{gap:20px}.section{padding:28px 0}footer{font-size:11px}.intro p{font-size:14px}}@media print{.hero,.plate{-webkit-print-color-adjust:exact;print-color-adjust:exact}.downloads{display:none}}
:root{--ink:#1b1e21;--muted:#62666b;--line:#dce0e2;--red:#d20f39}*{box-sizing:border-box}body{margin:0;background:#fafafa;color:var(--ink);font:16px/1.6 Plex,Arial,sans-serif}main,header,footer{width:min(1100px,calc(100% - 48px));margin:auto}header{display:flex;align-items:center;justify-content:space-between;padding:25px 0;border-bottom:1px solid var(--line)}header img{width:112px;height:40px}header span,.label{font:11px/1.5 ui-monospace,monospace;letter-spacing:.08em;text-transform:uppercase;color:var(--muted)}h1,h2,p{margin:0}h1{font-size:50px;line-height:1.1;font-weight:600;letter-spacing:-.05em;margin:18px 0}h2{font-size:22px;font-weight:600;letter-spacing:-.025em}a{color:inherit;text-underline-offset:4px}a:focus-visible{outline:3px solid var(--red);outline-offset:5px}.intro{padding:50px 0 35px}.intro p{max-width:580px;color:var(--muted)}.hero{display:flex;align-items:center;justify-content:center;background:#f1f2f3;border-radius:4px;min-height:320px;padding:35px}.hero img{width:min(560px,100%);height:auto}.variations{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:18px;margin:20px 0 45px}.variant{min-width:0}.plate{height:215px;display:flex;align-items:center;justify-content:center;background:var(--plate);border-radius:3px}.plate img{width:125px;height:125px}.variant p{display:flex;align-items:center;justify-content:space-between;gap:10px;font-size:12px;margin:12px 0 0}.variant p a{font-size:11px;color:var(--muted)}.section{padding:34px 0;border-top:1px solid var(--line)}.palette,.rules{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:30px;margin-top:23px}.swatch{display:flex;align-items:center;gap:13px;font-size:13px}.swatch i{display:block;width:42px;height:42px;background:var(--swatch);border:1px solid #0002;border-radius:3px}.swatch code{display:block;color:var(--muted);font-size:11px}.rules h3{font-size:14px;margin:0 0 9px}.rules p{font-size:13px;color:var(--muted)}.downloads{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:0 35px;margin-top:20px}.downloads a{display:flex;align-items:center;justify-content:space-between;gap:18px;padding:15px 0;border-bottom:1px solid var(--line);text-decoration:none;font-size:13px}.downloads a:hover{text-decoration:underline}.downloads span{font:10px ui-monospace,monospace;color:var(--muted);white-space:nowrap}.sizes{display:flex;gap:25px;align-items:flex-end;margin-top:25px}.sizes figure{margin:0;text-align:center}.sizes figcaption{font:10px ui-monospace,monospace;color:var(--muted);margin-top:10px}.sizes img{display:block}.app{background:var(--red);border-radius:13px;width:64px;height:64px;display:grid;place-items:center}.app img{width:54px;height:54px}footer{border-top:1px solid var(--line);padding:26px 0 40px;color:var(--muted);font-size:12px;display:flex;justify-content:space-between;gap:30px}@media(max-width:650px){main,header,footer{width:calc(100% - 32px)}header span{font-size:9px}h1{font-size:42px}.intro{padding-top:35px}.hero{min-height:220px;padding:20px}.variations{gap:10px}.plate{height:145px}.plate img{width:92px;height:92px}.variant p{flex-direction:column;align-items:flex-start;gap:4px}.palette,.rules{grid-template-columns:1fr;gap:22px}.downloads{grid-template-columns:1fr}.sizes{gap:20px}.section{padding:28px 0}footer{font-size:11px}.intro p{font-size:14px}}@media print{.hero,.plate{-webkit-print-color-adjust:exact;print-color-adjust:exact}.downloads{display:none}}
</style>
</head>
<body>
Expand All @@ -21,7 +21,7 @@
<div class="variant"><div class="plate" style="--plate:#eef0f2"><img src="../internal/web/static/brand/murmur-ink.svg" alt="Murmur in ink" width="125" height="125"></div><p>Monochrome <a href="../internal/web/static/brand/murmur-ink.svg" download>Download SVG ↓</a></p></div>
<div class="variant"><div class="plate" style="--plate:#1b1e21"><img src="../internal/web/static/brand/murmur-white.svg" alt="Murmur in white on a dark background" width="125" height="125"></div><p>Reverse <a href="../internal/web/static/brand/murmur-white.svg" download>Download SVG ↓</a></p></div>
</section>
<section class="section"><h2>Color</h2><div class="palette"><div class="swatch"><i style="--swatch:#bd3927"></i><div>Murmur red<code>#BD3927</code></div></div><div class="swatch"><i style="--swatch:#1b1e21"></i><div>Ink<code>#1B1E21</code></div></div><div class="swatch"><i style="--swatch:#ffffff"></i><div>White<code>#FFFFFF</code></div></div></div></section>
<section class="section"><h2>Color</h2><div class="palette"><div class="swatch"><i style="--swatch:#d20f39"></i><div>Red (Catppuccin Latte)<code>#D20F39</code></div></div><div class="swatch"><i style="--swatch:#1b1e21"></i><div>Ink<code>#1B1E21</code></div></div><div class="swatch"><i style="--swatch:#ffffff"></i><div>White<code>#FFFFFF</code></div></div></div></section>
<section class="section"><h2>Use with care</h2><div class="rules"><div><h3>Give it space.</h3><p>Leave clear space around the mark of at least one quarter of its displayed size. Keep the five forms together and preserve the open center.</p></div><div><h3>Keep the silhouette.</h3><p>Use the supplied SVGs for sharp rendering at any size. Scale proportionally, retain the orientation, and use the white version on dark surfaces.</p></div><div><h3>Choose the right format.</h3><p>Use the horizontal lockup where the name needs to lead. Use the symbol or app tile in compact spaces. The supplied favicon handles browser tabs.</p></div></div><div class="sizes" aria-label="Small-size examples"><figure><img src="../internal/web/static/brand/murmur.svg" width="16" height="16" alt="Murmur at 16 pixels"><figcaption>16 px</figcaption></figure><figure><img src="../internal/web/static/brand/murmur.svg" width="24" height="24" alt="Murmur at 24 pixels"><figcaption>24 px</figcaption></figure><figure><img src="../internal/web/static/brand/murmur.svg" width="32" height="32" alt="Murmur at 32 pixels"><figcaption>32 px</figcaption></figure><figure><img src="../internal/web/static/brand/murmur.svg" width="48" height="48" alt="Murmur at 48 pixels"><figcaption>48 px</figcaption></figure><figure><div class="app"><img src="../internal/web/static/brand/murmur-white.svg" width="54" height="54" alt="Murmur application tile"></div><figcaption>App tile</figcaption></figure></div></section>
<section class="section"><h2>Downloads</h2><div class="downloads"><a href="../internal/web/static/brand/lockup.svg" download>Horizontal lockup <span>SVG ↓</span></a><a href="../internal/web/static/brand/lockup-white.svg" download>White lockup <span>SVG ↓</span></a><a href="../internal/web/static/brand/lockup-ink.svg" download>Monochrome lockup <span>SVG ↓</span></a><a href="../internal/web/static/brand/lockup-stacked.svg" download>Stacked lockup <span>SVG ↓</span></a><a href="../internal/web/static/brand/murmur-1024.png" download>Transparent symbol <span>1024 px PNG ↓</span></a><a href="../internal/web/static/brand/lockup.png" download>Transparent lockup <span>1200 px PNG ↓</span></a><a href="../internal/web/static/brand/icon-512.png" download>Application icon <span>512 px PNG ↓</span></a><a href="../internal/web/static/brand/favicon.svg" download>Browser icon <span>SVG ↓</span></a></div></section>
<section class="section"><h2>Editable source</h2><p style="margin-top:16px;font-size:13px;max-width:680px;color:var(--muted)">Edit <a href="../internal/web/static/brand/murmur.svg">murmur.svg</a> for the five symbol shapes, or <a href="../internal/web/static/brand/wordmark.svg">wordmark.svg</a> for the lettering. Run <code>python3 scripts/generate-brand.py</code> to regenerate the variants and PNG exports. PNG rendering uses <code>rsvg-convert</code>; <code>--svg-only</code> generates just the vector variants. The master files are preserved. The selected geometry was traced from the approved concept artwork.</p></section>
Expand Down
18 changes: 10 additions & 8 deletions docs/specs/2026-10-24-desktop-app-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down Expand Up @@ -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.
Expand Down
9 changes: 5 additions & 4 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
18 changes: 18 additions & 0 deletions internal/chat/remote_deadlines_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
10 changes: 9 additions & 1 deletion internal/chat/transport.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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)
Expand Down
13 changes: 7 additions & 6 deletions internal/cli/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
6 changes: 5 additions & 1 deletion internal/cli/app_darwin.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
14 changes: 13 additions & 1 deletion internal/cli/app_darwin_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package cli

import "testing"
import (
"slices"
"testing"
)

func TestAppBundle(t *testing.T) {
for _, tt := range []struct{ exe, want string }{
Expand Down Expand Up @@ -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)
}
}
Loading
Loading