diff --git a/README.md b/README.md index 9ebe4be..f4dd7ef 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/cmd/omarchy-send/main.go b/cmd/omarchy-send/main.go index 491ff5c..654ca2f 100644 --- a/cmd/omarchy-send/main.go +++ b/cmd/omarchy-send/main.go @@ -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 -message . + 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 and -message ") + 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 +} diff --git a/internal/app/events.go b/internal/app/events.go index 2292ee0..e494b01 100644 --- a/internal/app/events.go +++ b/internal/app/events.go @@ -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)) { diff --git a/internal/app/events_test.go b/internal/app/events_test.go new file mode 100644 index 0000000..95bbeef --- /dev/null +++ b/internal/app/events_test.go @@ -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) + } +} diff --git a/internal/client/client.go b/internal/client/client.go index 695be23..ac638a7 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -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) } diff --git a/internal/client/message_sync_test.go b/internal/client/message_sync_test.go new file mode 100644 index 0000000..4099d74 --- /dev/null +++ b/internal/client/message_sync_test.go @@ -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) + } +} diff --git a/internal/client/prepare_204_test.go b/internal/client/prepare_204_test.go new file mode 100644 index 0000000..790ef3c --- /dev/null +++ b/internal/client/prepare_204_test.go @@ -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) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index a42d38e..5ec3378 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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"` diff --git a/internal/discovery/discovery.go b/internal/discovery/discovery.go index 0e74b86..707b5a5 100644 --- a/internal/discovery/discovery.go +++ b/internal/discovery/discovery.go @@ -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) { diff --git a/internal/discovery/find_test.go b/internal/discovery/find_test.go new file mode 100644 index 0000000..5a09618 --- /dev/null +++ b/internal/discovery/find_test.go @@ -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") + } +} diff --git a/internal/notify/notify.go b/internal/notify/notify.go new file mode 100644 index 0000000..092e476 --- /dev/null +++ b/internal/notify/notify.go @@ -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) + } + }() +} diff --git a/internal/tui/model.go b/internal/tui/model.go index f3d5565..b8e5705 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -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" }