Headless one-shot message send + desktop notifications on incoming

Two related additions for using omarchy-send beyond the focused TUI:

Headless send (no TUI / no TTY) — for scripts, cron, SSH sessions:
  omarchy-send --to "<alias>" --message "<text>" [--send-pin N] [--wait 15s]
Resolves the peer by alias over multicast (discovery only, not the HTTP
receiver, so it co-exists with a running instance), sends, prints a
one-line result, and exits non-zero on not-found / failure. New
discovery.FindPeer/Snapshot and client.SendMessageSync (returns the error
directly, incl. ErrPinRequired).

Bugfix surfaced by the above: the official LocalSend client answers a
message prepare-upload with HTTP 204 No Content (the text rides in the
preview field, nothing to upload). prepareUpload only accepted 200, so
message sends to official peers failed with "prepare-upload status 204"
in BOTH the new headless path and the existing TUI. Now treats 204 as
success (empty response).

Desktop notifications: a running receiver raises a notify-send
notification on an incoming message or file offer, so mako shows it on
Omarchy/Hyprland even when the TUI isn't focused. New internal/notify
(best-effort; self-disabling on headless boxes with no notify-send /
session bus). Off-switch: --no-notify flag, cfg.NoNotify, and a Settings
'n' toggle wired to a live atomic gate via Controller.SetNotify so it
takes effect without a restart.

Tests: discovery/find_test, client/message_sync_test,
client/prepare_204_test, app/events_test. Race-clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
28allday 2026-05-29 14:13:43 +01:00
parent 098c34c546
commit e66662feb9
12 changed files with 528 additions and 12 deletions

View file

@ -20,6 +20,11 @@ LocalSend mobile and desktop apps on the same LAN, including their default
clipboard as a message, or copy a received one back to the clipboard (uses
`wl-clipboard`/`xclip`/`xsel`, or tmux's paste buffer when run inside tmux on
a headless box).
- **Desktop notifications** — on a graphical session (e.g. Omarchy/Hyprland with
`mako`), an incoming message or file offer raises a desktop notification via
`notify-send`, so a backgrounded receiver still gets your attention. Best-effort
and self-disabling on headless boxes; turn it off with `--no-notify` or the `n`
key in Settings.
- **Manage** — browse the receive folder, mark received files (or whole folders)
and delete the ones you no longer want, behind a confirmation prompt.
- **HTTPS** — generates a self-signed certificate whose fingerprint matches the
@ -65,8 +70,27 @@ omarchy-send --dir ~/Downloads # override the receive directory
omarchy-send --auto-accept # accept incoming transfers without a prompt
omarchy-send --pin 2468 # require senders to supply this PIN
omarchy-send --no-icons # drop Nerd Font glyphs (non-Nerd-Font terminals)
omarchy-send --no-notify # don't raise desktop notifications on incoming
```
### Headless send (no TUI)
Send a one-off message to a peer by name, with no terminal UI — handy from
scripts, cron, or an SSH session with no TTY:
```sh
omarchy-send --to "Strong Onion" --message "hello"
omarchy-send --to "Strong Onion" --message "deploy finished" --wait 20s
omarchy-send --to "Strong Onion" --message "hi" --send-pin 2468 # if the peer requires a PIN
```
The target is matched against the peer's display name, case-insensitively. The
command discovers the peer over multicast (waiting up to `--wait`, default 15s),
sends the message, prints a one-line result, and exits non-zero if the peer
isn't found or the send fails. It starts discovery only — not the receiver — so
it's safe to run while another `omarchy-send` instance is up. Both `--to` and
`--message` are required; file sending stays in the TUI for now.
### Theming
On Omarchy, the TUI reads the active theme's `~/.config/omarchy/current/theme/colors.toml`
@ -106,7 +130,7 @@ omarchy-send --auto-accept --pin 2468
delete it (incoming messages arrive automatically, with a footer notice)
- Manage: `space` mark file/folder · `a` mark all · `d` delete marked (or the one
under the cursor) · `r` refresh · `/` filter — deletion asks to confirm first
- Settings: `e` edit (alias / receive dir / PIN) · `a` toggle auto-accept
- Settings: `e` edit (alias / receive dir / PIN) · `a` toggle auto-accept · `i` toggle icons · `n` toggle notifications
- Sending to a PIN-protected peer prompts for the PIN and retries
- `q` quit

View file

@ -5,10 +5,14 @@ package main
import (
"context"
"crypto/tls"
"errors"
"flag"
"fmt"
"log"
"os"
"strings"
"sync/atomic"
"time"
tea "github.com/charmbracelet/bubbletea"
@ -17,7 +21,9 @@ import (
"omarchy-send/internal/config"
"omarchy-send/internal/dbg"
"omarchy-send/internal/discovery"
"omarchy-send/internal/notify"
"omarchy-send/internal/server"
"omarchy-send/internal/transfer"
"omarchy-send/internal/tui"
)
@ -26,6 +32,7 @@ type controller struct {
disc *discovery.Discoverer
sender *client.Sender
srv *server.Server
notify *atomic.Bool // live gate for desktop notifications (toggled from Settings)
}
func (c controller) Announce() { c.disc.Announce() }
@ -36,6 +43,7 @@ func (c controller) SendMessage(p discovery.Peer, text, pin string) {
func (c controller) SetAutoAccept(v bool) { c.srv.SetAutoAccept(v) }
func (c controller) SetPIN(pin string) { c.srv.SetPIN(pin) }
func (c controller) SetReceiveDir(dir string) { c.srv.SetReceiveDir(dir) }
func (c controller) SetNotify(v bool) { c.notify.Store(v) }
// SetAlias updates the alias across all services and re-announces it.
func (c controller) SetAlias(alias string) {
@ -53,6 +61,13 @@ func main() {
pinFlag = flag.String("pin", "", "require this PIN from senders (overrides config)")
autoFlag = flag.Bool("auto-accept", false, "auto-accept incoming transfers (no prompt)")
noIcons = flag.Bool("no-icons", false, "hide Nerd Font device icons (for non-Nerd-Font terminals)")
noNotify = flag.Bool("no-notify", false, "don't raise desktop notifications on incoming messages/files")
// Headless one-shot send (no TUI): -to <alias> -message <text>.
toFlag = flag.String("to", "", "headless send: target peer alias to send to (no TUI); requires -message")
messageFlag = flag.String("message", "", "headless send: plain-text message to send to -to")
sendPINFlag = flag.String("send-pin", "", "headless send: PIN to present if the target peer requires one")
waitFlag = flag.Duration("wait", 15*time.Second, "headless send: how long to wait for the target peer to be discovered")
)
flag.Parse()
@ -85,6 +100,19 @@ func main() {
if *noIcons {
cfg.NoIcons = true
}
if *noNotify {
cfg.NoNotify = true
}
// Headless one-shot send: resolve the target by alias over discovery, send,
// and exit — no TUI, no terminal required. Suitable for scripts and cron.
if *toFlag != "" || *messageFlag != "" {
if *toFlag == "" || *messageFlag == "" {
fmt.Fprintln(os.Stderr, "headless send needs both -to <alias> and -message <text>")
os.Exit(2)
}
os.Exit(runHeadlessSend(cfg, *toFlag, *messageFlag, *sendPINFlag, *waitFlag))
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
@ -119,11 +147,23 @@ func main() {
}
sender := client.New(cfg.DeviceInfo())
ctrl := controller{disc: disc, sender: sender, srv: srv}
// notifyOn is the live notification gate: the user's preference (-no-notify /
// the Settings toggle), AND only meaningful where notify-send can actually
// reach a daemon. notify.Send itself no-ops when unavailable, so the gate
// only carries the user preference here.
notifyOn := &atomic.Bool{}
notifyOn.Store(!cfg.NoNotify)
ctrl := controller{disc: disc, sender: sender, srv: srv, notify: notifyOn}
p := tea.NewProgram(tui.New(cfg, ctrl), tea.WithAltScreen())
app.BridgeDiscovery(ctx, disc.Events(), p.Send)
app.BridgeServer(ctx, srv.Accepts(), srv.Transfers(), srv.Messages(), p.Send)
notifyFn := func(summary, body string) {
if notifyOn.Load() {
notify.Send(summary, body)
}
}
app.BridgeServer(ctx, srv.Accepts(), srv.Transfers(), srv.Messages(), p.Send, notifyFn)
app.BridgeTransfers(ctx, sender.Events(), p.Send)
disc.Announce() // announce immediately so we appear without waiting a tick
@ -132,3 +172,55 @@ func main() {
os.Exit(1)
}
}
// runHeadlessSend discovers the peer whose alias matches target (case-
// insensitively), sends it a plain-text message, and returns a process exit
// code. It deliberately starts only discovery — not the HTTP receiver — so it
// can run alongside an already-running instance without fighting over the
// listen port. Status goes to stderr; the success line goes to stdout.
func runHeadlessSend(cfg config.Config, target, message, sendPIN string, wait time.Duration) int {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
disc := discovery.New(cfg.DeviceInfo())
if err := disc.Run(ctx); err != nil {
fmt.Fprintf(os.Stderr, "discovery: %v\n", err)
return 1
}
disc.Announce() // solicit replies immediately rather than waiting a tick
want := strings.TrimSpace(target)
fmt.Fprintf(os.Stderr, "Looking for %q on the network (up to %s)…\n", want, wait)
findCtx, findCancel := context.WithTimeout(ctx, wait)
defer findCancel()
peer, err := disc.FindPeer(findCtx, func(p discovery.Peer) bool {
return strings.EqualFold(strings.TrimSpace(p.Info.Alias), want)
})
if err != nil {
fmt.Fprintf(os.Stderr, "no peer named %q found within %s.\n", want, wait)
if seen := disc.Snapshot(); len(seen) > 0 {
fmt.Fprintln(os.Stderr, "Peers seen:")
for _, p := range seen {
fmt.Fprintf(os.Stderr, " - %q (%s)\n", p.Info.Alias, p.IP)
}
} else {
fmt.Fprintln(os.Stderr, "No peers were seen at all — check you're on the same LAN and the target is running omarchy-send / LocalSend.")
}
return 1
}
sender := client.New(cfg.DeviceInfo())
if err := sender.SendMessageSync(peer, message, sendPIN); err != nil {
switch {
case errors.Is(err, transfer.ErrPinRequired):
fmt.Fprintf(os.Stderr, "%q requires a PIN — pass it with -send-pin.\n", peer.Info.Alias)
default:
fmt.Fprintf(os.Stderr, "send to %q (%s) failed: %v\n", peer.Info.Alias, peer.IP, err)
}
return 1
}
fmt.Printf("Message sent to %q (%s).\n", peer.Info.Alias, peer.IP)
return 0
}

View file

@ -5,6 +5,7 @@ package app
import (
"context"
"fmt"
tea "github.com/charmbracelet/bubbletea"
@ -30,24 +31,57 @@ type TransferMsg struct{ Ev transfer.Event }
type MessageMsg struct{ Msg server.ReceivedMessage }
// BridgeServer forwards the server's accept requests, transfer events, and
// received messages to the Tea program until ctx is cancelled.
func BridgeServer(ctx context.Context, accepts <-chan server.AcceptRequest, transfers <-chan transfer.Event, messages <-chan server.ReceivedMessage, send func(tea.Msg)) {
// received messages to the Tea program until ctx is cancelled. notify, if
// non-nil, is called (summary, body) for events worth a desktop notification —
// an inbound message or a peer offering files — so a backgrounded receiver
// surfaces them through the desktop's notification daemon.
func BridgeServer(ctx context.Context, accepts <-chan server.AcceptRequest, transfers <-chan transfer.Event, messages <-chan server.ReceivedMessage, send func(tea.Msg), notify func(summary, body string)) {
go func() {
for {
select {
case <-ctx.Done():
return
case req := <-accepts:
if notify != nil {
notify(notifyTitleFrom(req.From.Alias)+" wants to send files", incomingFilesBody(req))
}
send(IncomingMsg{Req: req})
case ev := <-transfers:
send(TransferMsg{Ev: ev})
case m := <-messages:
if notify != nil {
notify("Message from "+nonEmptyAlias(m.From), m.Text)
}
send(MessageMsg{Msg: m})
}
}
}()
}
// nonEmptyAlias returns alias, or a generic stand-in when a peer sent no name.
func nonEmptyAlias(alias string) string {
if alias == "" {
return "a device"
}
return alias
}
func notifyTitleFrom(alias string) string { return nonEmptyAlias(alias) }
// incomingFilesBody summarises an incoming file offer for a notification body,
// naming the file when there's only one and counting them otherwise.
func incomingFilesBody(req server.AcceptRequest) string {
switch len(req.Files) {
case 0:
return "Incoming transfer"
case 1:
for _, f := range req.Files {
return f.FileName
}
}
return fmt.Sprintf("%d files", len(req.Files))
}
// BridgeTransfers forwards a transfer event channel (e.g. the sender's) to the
// Tea program until ctx is cancelled.
func BridgeTransfers(ctx context.Context, transfers <-chan transfer.Event, send func(tea.Msg)) {

View file

@ -0,0 +1,70 @@
package app
import (
"context"
"testing"
"time"
tea "github.com/charmbracelet/bubbletea"
"omarchy-send/internal/protocol"
"omarchy-send/internal/server"
"omarchy-send/internal/transfer"
)
// An inbound message must drive the notify callback with the sender's name and
// the message text, so a backgrounded receiver surfaces it on the desktop.
func TestBridgeServerNotifiesOnMessage(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
accepts := make(chan server.AcceptRequest)
transfers := make(chan transfer.Event)
messages := make(chan server.ReceivedMessage, 1)
type call struct{ summary, body string }
got := make(chan call, 1)
notify := func(summary, body string) { got <- call{summary, body} }
BridgeServer(ctx, accepts, transfers, messages, func(tea.Msg) {}, notify)
messages <- server.ReceivedMessage{From: "Strong Onion", Text: "dinner's ready"}
select {
case c := <-got:
if c.summary != "Message from Strong Onion" {
t.Errorf("summary = %q", c.summary)
}
if c.body != "dinner's ready" {
t.Errorf("body = %q", c.body)
}
case <-time.After(time.Second):
t.Fatal("notify was not called for an inbound message")
}
}
// incomingFilesBody names a single file and counts multiple ones.
func TestIncomingFilesBody(t *testing.T) {
one := server.AcceptRequest{Files: map[string]protocol.FileMetadata{
"a": {FileName: "photo.jpg"},
}}
if got := incomingFilesBody(one); got != "photo.jpg" {
t.Errorf("single file body = %q, want photo.jpg", got)
}
many := server.AcceptRequest{Files: map[string]protocol.FileMetadata{
"a": {FileName: "x"}, "b": {FileName: "y"}, "c": {FileName: "z"},
}}
if got := incomingFilesBody(many); got != "3 files" {
t.Errorf("multi file body = %q, want \"3 files\"", got)
}
}
func TestNonEmptyAlias(t *testing.T) {
if got := nonEmptyAlias(""); got != "a device" {
t.Errorf("empty alias = %q", got)
}
if got := nonEmptyAlias("Slate Starburst"); got != "Slate Starburst" {
t.Errorf("alias = %q", got)
}
}

View file

@ -103,6 +103,18 @@ func (s *Sender) SendMessage(peer discovery.Peer, text, pin string) {
}
func (s *Sender) sendMessage(peer discovery.Peer, text, pin string) {
if err := s.SendMessageSync(peer, text, pin); err != nil {
dbg.Logf("send message to %s failed: %v", peer.IP, err)
s.emit(transfer.Event{Dir: transfer.Outgoing, Kind: transfer.Error, FileName: "message", Err: err})
}
}
// SendMessageSync sends a plain-text message to peer and blocks until the peer
// has accepted it (or an error occurs), returning that error directly —
// including transfer.ErrPinRequired when the peer needs a PIN. Unlike
// SendMessage it reports nothing on Events(); it exists for the headless
// one-shot send path, where there is no TUI to consume events.
func (s *Sender) SendMessageSync(peer discovery.Peer, text, pin string) error {
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
@ -116,10 +128,8 @@ func (s *Sender) sendMessage(peer discovery.Peer, text, pin string) {
Preview: text,
},
}
if _, err := s.prepareUpload(ctx, s.url(peer), files, pin); err != nil {
dbg.Logf("send message to %s failed: %v", peer.IP, err)
s.emit(transfer.Event{Dir: transfer.Outgoing, Kind: transfer.Error, FileName: "message", Err: err})
}
_, err := s.prepareUpload(ctx, s.url(peer), files, pin)
return err
}
func (s *Sender) send(peer discovery.Peer, paths []string, pin string) {
@ -282,6 +292,14 @@ func (s *Sender) prepareUpload(ctx context.Context, base string, files map[strin
if resp.StatusCode == http.StatusUnauthorized {
return protocol.PrepareUploadResponse{}, transfer.ErrPinRequired
}
if resp.StatusCode == http.StatusNoContent {
// 204: accepted, but nothing to upload — the official LocalSend client
// returns this for a message (its text rode in the preview field) and
// for files the peer already has. No session/token map follows, so
// return an empty response: a message send is done, and a file send
// simply finds no tokens to push, which is the correct outcome.
return protocol.PrepareUploadResponse{}, nil
}
if resp.StatusCode != http.StatusOK {
return protocol.PrepareUploadResponse{}, fmt.Errorf("prepare-upload status %d", resp.StatusCode)
}

View file

@ -0,0 +1,74 @@
package client
import (
"context"
"errors"
"testing"
"time"
"omarchy-send/internal/discovery"
"omarchy-send/internal/protocol"
"omarchy-send/internal/server"
"omarchy-send/internal/transfer"
)
// SendMessageSync delivers the message and returns nil, with the text arriving
// on the receiver's Messages channel — the synchronous path used by headless
// `-to/-message` sends.
func TestSendMessageSyncSuccess(t *testing.T) {
recvInfo := protocol.DeviceInfo{
Alias: "recv", Version: protocol.ProtocolVersion, Port: 53991, Protocol: "http",
}
srv := server.New(server.Options{Info: recvInfo, ReceiveDir: t.TempDir(), AutoAccept: false})
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
if err := srv.Start(ctx); err != nil {
t.Fatalf("server start: %v", err)
}
go func() {
for range srv.Transfers() {
}
}()
time.Sleep(50 * time.Millisecond)
sender := New(protocol.DeviceInfo{Alias: "cli", Fingerprint: "cli1", Version: "2.1", Protocol: "http"})
peer := discovery.Peer{Info: recvInfo, IP: "127.0.0.1"}
if err := sender.SendMessageSync(peer, "headless hello", ""); err != nil {
t.Fatalf("SendMessageSync: %v", err)
}
select {
case m := <-srv.Messages():
if m.Text != "headless hello" {
t.Fatalf("message text = %q", m.Text)
}
case <-time.After(3 * time.Second):
t.Fatal("timed out waiting for the message")
}
}
// A peer that requires a PIN rejects a PIN-less send with ErrPinRequired, so the
// CLI can tell the user to pass -send-pin.
func TestSendMessageSyncPinRequired(t *testing.T) {
recvInfo := protocol.DeviceInfo{
Alias: "recv", Version: protocol.ProtocolVersion, Port: 53992, Protocol: "http",
}
srv := server.New(server.Options{Info: recvInfo, ReceiveDir: t.TempDir(), PIN: "2468"})
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
if err := srv.Start(ctx); err != nil {
t.Fatalf("server start: %v", err)
}
time.Sleep(50 * time.Millisecond)
sender := New(protocol.DeviceInfo{Alias: "cli", Fingerprint: "cli1", Version: "2.1", Protocol: "http"})
peer := discovery.Peer{Info: recvInfo, IP: "127.0.0.1"}
err := sender.SendMessageSync(peer, "no pin", "")
if !errors.Is(err, transfer.ErrPinRequired) {
t.Fatalf("err = %v, want ErrPinRequired", err)
}
// With the correct PIN it goes through.
if err := sender.SendMessageSync(peer, "with pin", "2468"); err != nil {
t.Fatalf("SendMessageSync with PIN: %v", err)
}
}

View file

@ -0,0 +1,43 @@
package client
import (
"net"
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"strings"
"testing"
"omarchy-send/internal/discovery"
"omarchy-send/internal/protocol"
)
// The official LocalSend client answers a message prepare-upload with 204 No
// Content (the text rode in the preview field, so nothing needs uploading).
// SendMessageSync must treat that as success, not an error.
func TestSendMessageSync204IsSuccess(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.HasSuffix(r.URL.Path, protocol.PathPrepareUpload) {
t.Errorf("unexpected request to %s", r.URL.Path)
}
w.WriteHeader(http.StatusNoContent)
}))
defer ts.Close()
u, _ := url.Parse(ts.URL)
host, portStr, err := net.SplitHostPort(u.Host)
if err != nil {
t.Fatalf("split host: %v", err)
}
port, _ := strconv.Atoi(portStr)
sender := New(protocol.DeviceInfo{Alias: "cli", Fingerprint: "cli1", Version: "2.1", Protocol: "http"})
peer := discovery.Peer{
Info: protocol.DeviceInfo{Alias: "official", Protocol: "http", Port: port},
IP: host,
}
if err := sender.SendMessageSync(peer, "hi", ""); err != nil {
t.Fatalf("204 should be success, got: %v", err)
}
}

View file

@ -21,8 +21,9 @@ type Config struct {
DeviceType string `json:"deviceType"`
Protocol string `json:"protocol"`
AutoAccept bool `json:"autoAccept"`
PIN string `json:"pin"` // if set, senders must supply it
NoIcons bool `json:"noIcons"` // hide Nerd Font device icons (non-NF terminals)
PIN string `json:"pin"` // if set, senders must supply it
NoIcons bool `json:"noIcons"` // hide Nerd Font device icons (non-NF terminals)
NoNotify bool `json:"noNotify"` // don't raise desktop notifications on incoming messages/files
// TLS identity for encrypted (HTTPS) mode, generated once and persisted.
CertPEM string `json:"certPem"`

View file

@ -83,6 +83,43 @@ func New(self protocol.DeviceInfo) *Discoverer {
// Events returns the channel on which peer events are delivered.
func (d *Discoverer) Events() <-chan Event { return d.events }
// Snapshot returns a copy of the currently-known peers.
func (d *Discoverer) Snapshot() []Peer {
d.mu.Lock()
defer d.mu.Unlock()
out := make([]Peer, 0, len(d.peers))
for _, p := range d.peers {
out = append(out, p)
}
return out
}
// FindPeer waits for a known peer satisfying pred, checking peers already seen
// first and then ones discovered while waiting. It returns the first match, or
// ctx.Err() if the context is cancelled / times out first. It consumes the
// Events() channel, so it must not run concurrently with another Events()
// reader (e.g. the TUI bridge) — it is intended for the headless send path.
func (d *Discoverer) FindPeer(ctx context.Context, pred func(Peer) bool) (Peer, error) {
for _, p := range d.Snapshot() {
if pred(p) {
return p, nil
}
}
for {
select {
case <-ctx.Done():
return Peer{}, ctx.Err()
case ev, ok := <-d.events:
if !ok {
return Peer{}, ctx.Err()
}
if ev.Kind == PeerFound && pred(ev.Peer) {
return ev.Peer, nil
}
}
}
}
// SetAlias updates the advertised alias (and device model) at runtime. Call
// Announce afterwards to push it out immediately.
func (d *Discoverer) SetAlias(alias string) {

View file

@ -0,0 +1,56 @@
package discovery
import (
"context"
"testing"
"time"
)
// FindPeer returns immediately for a peer already in the snapshot.
func TestFindPeerAlreadyKnown(t *testing.T) {
d := New(mkInfo("self", "selffp", 0))
d.NotePeer(mkInfo("Strong Onion", "peerfp", 53317), "192.168.1.50")
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
// Match is case-insensitive and whitespace-trimmed, like the CLI.
got, err := d.FindPeer(ctx, func(p Peer) bool {
return p.Info.Alias == "Strong Onion"
})
if err != nil {
t.Fatalf("FindPeer: %v", err)
}
if got.IP != "192.168.1.50" {
t.Errorf("IP = %q, want 192.168.1.50", got.IP)
}
}
// FindPeer resolves a peer that only appears after the call starts.
func TestFindPeerArrivesLater(t *testing.T) {
d := New(mkInfo("self", "selffp", 0))
go func() {
time.Sleep(50 * time.Millisecond)
d.NotePeer(mkInfo("Latecomer", "latefp", 53317), "10.0.0.9")
}()
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
got, err := d.FindPeer(ctx, func(p Peer) bool { return p.Info.Alias == "Latecomer" })
if err != nil {
t.Fatalf("FindPeer: %v", err)
}
if got.IP != "10.0.0.9" {
t.Errorf("IP = %q, want 10.0.0.9", got.IP)
}
}
// FindPeer returns the context error when no match arrives in time.
func TestFindPeerTimeout(t *testing.T) {
d := New(mkInfo("self", "selffp", 0))
ctx, cancel := context.WithTimeout(context.Background(), 80*time.Millisecond)
defer cancel()
if _, err := d.FindPeer(ctx, func(Peer) bool { return false }); err == nil {
t.Fatal("expected a timeout error, got nil")
}
}

56
internal/notify/notify.go Normal file
View file

@ -0,0 +1,56 @@
// Package notify raises desktop notifications via libnotify's notify-send,
// which on Omarchy/Hyprland is displayed by the mako daemon. It is best-effort:
// when notify-send is absent or there is no graphical session bus (a genuinely
// headless box), it silently does nothing, so the receiver never blocks or
// errors on a machine with no desktop.
package notify
import (
"context"
"os"
"os/exec"
"time"
"omarchy-send/internal/dbg"
)
// appName is shown as the originating application in the notification daemon,
// and iconName is the bundled hicolor icon the installer drops in (falls back to
// no icon if the theme lacks it — harmless).
const (
appName = "Omarchy-Send"
iconName = "omarchy-send"
)
// Available reports whether a desktop notification can plausibly be shown:
// notify-send is on PATH and we appear to be inside a graphical session. The
// TUI calls this once at startup to decide whether to enable notifications.
func Available() bool {
if _, err := exec.LookPath("notify-send"); err != nil {
return false
}
return os.Getenv("WAYLAND_DISPLAY") != "" ||
os.Getenv("DISPLAY") != "" ||
os.Getenv("DBUS_SESSION_BUS_ADDRESS") != ""
}
// Send shows a desktop notification with the given summary and body. It returns
// immediately; the notify-send invocation runs in the background (bounded by a
// short timeout) and any failure is recorded only in the debug log. It is a
// no-op when Available reports false, so it is safe to call unconditionally.
func Send(summary, body string) {
if !Available() {
return
}
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "notify-send",
"-a", appName,
"-i", iconName,
summary, body)
if err := cmd.Run(); err != nil {
dbg.Logf("notify-send failed: %v", err)
}
}()
}

View file

@ -38,6 +38,7 @@ type Controller interface {
SetAlias(string)
SetReceiveDir(string)
SetPIN(string)
SetNotify(bool)
}
type screen int
@ -426,6 +427,15 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
_ = m.cfg.Save()
}
return m, nil
case "n":
if m.screen == screenSettings {
m.cfg.NoNotify = !m.cfg.NoNotify
if m.ctrl != nil {
m.ctrl.SetNotify(!m.cfg.NoNotify)
}
_ = m.cfg.Save()
}
return m, nil
case "e":
if m.screen == screenSettings {
return m.beginEdit()
@ -1124,6 +1134,7 @@ func (m Model) settingsView() string {
{"Auto-accept", boolStr(m.autoAccept)},
{"PIN", boolStr(m.cfg.PIN != "")},
{"Icons", boolStr(!m.cfg.NoIcons)},
{"Notifications", boolStr(!m.cfg.NoNotify)},
{"Local IPs", strings.Join(m.ips, ", ")},
}
for _, r := range rows {
@ -1174,7 +1185,7 @@ func (m Model) footerText() string {
case m.screen == screenMessages:
return "enter read · y copy · d delete · 1-5 switch · q quit"
case m.screen == screenSettings:
return "e edit · a auto-accept · i icons · 1-5 switch · q quit"
return "e edit · a auto-accept · i icons · n notify · 1-5 switch · q quit"
}
return "q quit"
}