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:
parent
098c34c546
commit
e66662feb9
12 changed files with 528 additions and 12 deletions
26
README.md
26
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
|
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
|
`wl-clipboard`/`xclip`/`xsel`, or tmux's paste buffer when run inside tmux on
|
||||||
a headless box).
|
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)
|
- **Manage** — browse the receive folder, mark received files (or whole folders)
|
||||||
and delete the ones you no longer want, behind a confirmation prompt.
|
and delete the ones you no longer want, behind a confirmation prompt.
|
||||||
- **HTTPS** — generates a self-signed certificate whose fingerprint matches the
|
- **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 --auto-accept # accept incoming transfers without a prompt
|
||||||
omarchy-send --pin 2468 # require senders to supply this PIN
|
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-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
|
### Theming
|
||||||
|
|
||||||
On Omarchy, the TUI reads the active theme's `~/.config/omarchy/current/theme/colors.toml`
|
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)
|
delete it (incoming messages arrive automatically, with a footer notice)
|
||||||
- Manage: `space` mark file/folder · `a` mark all · `d` delete marked (or the one
|
- 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
|
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
|
- Sending to a PIN-protected peer prompts for the PIN and retries
|
||||||
- `q` quit
|
- `q` quit
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,10 +5,14 @@ package main
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
|
"errors"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
|
||||||
|
|
@ -17,7 +21,9 @@ import (
|
||||||
"omarchy-send/internal/config"
|
"omarchy-send/internal/config"
|
||||||
"omarchy-send/internal/dbg"
|
"omarchy-send/internal/dbg"
|
||||||
"omarchy-send/internal/discovery"
|
"omarchy-send/internal/discovery"
|
||||||
|
"omarchy-send/internal/notify"
|
||||||
"omarchy-send/internal/server"
|
"omarchy-send/internal/server"
|
||||||
|
"omarchy-send/internal/transfer"
|
||||||
"omarchy-send/internal/tui"
|
"omarchy-send/internal/tui"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -26,6 +32,7 @@ type controller struct {
|
||||||
disc *discovery.Discoverer
|
disc *discovery.Discoverer
|
||||||
sender *client.Sender
|
sender *client.Sender
|
||||||
srv *server.Server
|
srv *server.Server
|
||||||
|
notify *atomic.Bool // live gate for desktop notifications (toggled from Settings)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c controller) Announce() { c.disc.Announce() }
|
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) SetAutoAccept(v bool) { c.srv.SetAutoAccept(v) }
|
||||||
func (c controller) SetPIN(pin string) { c.srv.SetPIN(pin) }
|
func (c controller) SetPIN(pin string) { c.srv.SetPIN(pin) }
|
||||||
func (c controller) SetReceiveDir(dir string) { c.srv.SetReceiveDir(dir) }
|
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.
|
// SetAlias updates the alias across all services and re-announces it.
|
||||||
func (c controller) SetAlias(alias string) {
|
func (c controller) SetAlias(alias string) {
|
||||||
|
|
@ -53,6 +61,13 @@ func main() {
|
||||||
pinFlag = flag.String("pin", "", "require this PIN from senders (overrides config)")
|
pinFlag = flag.String("pin", "", "require this PIN from senders (overrides config)")
|
||||||
autoFlag = flag.Bool("auto-accept", false, "auto-accept incoming transfers (no prompt)")
|
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)")
|
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()
|
flag.Parse()
|
||||||
|
|
||||||
|
|
@ -85,6 +100,19 @@ func main() {
|
||||||
if *noIcons {
|
if *noIcons {
|
||||||
cfg.NoIcons = true
|
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())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
@ -119,11 +147,23 @@ func main() {
|
||||||
}
|
}
|
||||||
|
|
||||||
sender := client.New(cfg.DeviceInfo())
|
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())
|
p := tea.NewProgram(tui.New(cfg, ctrl), tea.WithAltScreen())
|
||||||
app.BridgeDiscovery(ctx, disc.Events(), p.Send)
|
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)
|
app.BridgeTransfers(ctx, sender.Events(), p.Send)
|
||||||
disc.Announce() // announce immediately so we appear without waiting a tick
|
disc.Announce() // announce immediately so we appear without waiting a tick
|
||||||
|
|
||||||
|
|
@ -132,3 +172,55 @@ func main() {
|
||||||
os.Exit(1)
|
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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ package app
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
|
||||||
|
|
@ -30,24 +31,57 @@ type TransferMsg struct{ Ev transfer.Event }
|
||||||
type MessageMsg struct{ Msg server.ReceivedMessage }
|
type MessageMsg struct{ Msg server.ReceivedMessage }
|
||||||
|
|
||||||
// BridgeServer forwards the server's accept requests, transfer events, and
|
// BridgeServer forwards the server's accept requests, transfer events, and
|
||||||
// received messages to the Tea program until ctx is cancelled.
|
// received messages to the Tea program until ctx is cancelled. notify, if
|
||||||
func BridgeServer(ctx context.Context, accepts <-chan server.AcceptRequest, transfers <-chan transfer.Event, messages <-chan server.ReceivedMessage, send func(tea.Msg)) {
|
// 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() {
|
go func() {
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return
|
return
|
||||||
case req := <-accepts:
|
case req := <-accepts:
|
||||||
|
if notify != nil {
|
||||||
|
notify(notifyTitleFrom(req.From.Alias)+" wants to send files", incomingFilesBody(req))
|
||||||
|
}
|
||||||
send(IncomingMsg{Req: req})
|
send(IncomingMsg{Req: req})
|
||||||
case ev := <-transfers:
|
case ev := <-transfers:
|
||||||
send(TransferMsg{Ev: ev})
|
send(TransferMsg{Ev: ev})
|
||||||
case m := <-messages:
|
case m := <-messages:
|
||||||
|
if notify != nil {
|
||||||
|
notify("Message from "+nonEmptyAlias(m.From), m.Text)
|
||||||
|
}
|
||||||
send(MessageMsg{Msg: m})
|
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
|
// BridgeTransfers forwards a transfer event channel (e.g. the sender's) to the
|
||||||
// Tea program until ctx is cancelled.
|
// Tea program until ctx is cancelled.
|
||||||
func BridgeTransfers(ctx context.Context, transfers <-chan transfer.Event, send func(tea.Msg)) {
|
func BridgeTransfers(ctx context.Context, transfers <-chan transfer.Event, send func(tea.Msg)) {
|
||||||
|
|
|
||||||
70
internal/app/events_test.go
Normal file
70
internal/app/events_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -103,6 +103,18 @@ func (s *Sender) SendMessage(peer discovery.Peer, text, pin string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
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)
|
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
|
|
@ -116,10 +128,8 @@ func (s *Sender) sendMessage(peer discovery.Peer, text, pin string) {
|
||||||
Preview: text,
|
Preview: text,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
if _, err := s.prepareUpload(ctx, s.url(peer), files, pin); err != nil {
|
_, err := s.prepareUpload(ctx, s.url(peer), files, pin)
|
||||||
dbg.Logf("send message to %s failed: %v", peer.IP, err)
|
return err
|
||||||
s.emit(transfer.Event{Dir: transfer.Outgoing, Kind: transfer.Error, FileName: "message", Err: err})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Sender) send(peer discovery.Peer, paths []string, pin string) {
|
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 {
|
if resp.StatusCode == http.StatusUnauthorized {
|
||||||
return protocol.PrepareUploadResponse{}, transfer.ErrPinRequired
|
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 {
|
if resp.StatusCode != http.StatusOK {
|
||||||
return protocol.PrepareUploadResponse{}, fmt.Errorf("prepare-upload status %d", resp.StatusCode)
|
return protocol.PrepareUploadResponse{}, fmt.Errorf("prepare-upload status %d", resp.StatusCode)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
74
internal/client/message_sync_test.go
Normal file
74
internal/client/message_sync_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
43
internal/client/prepare_204_test.go
Normal file
43
internal/client/prepare_204_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -21,8 +21,9 @@ type Config struct {
|
||||||
DeviceType string `json:"deviceType"`
|
DeviceType string `json:"deviceType"`
|
||||||
Protocol string `json:"protocol"`
|
Protocol string `json:"protocol"`
|
||||||
AutoAccept bool `json:"autoAccept"`
|
AutoAccept bool `json:"autoAccept"`
|
||||||
PIN string `json:"pin"` // if set, senders must supply it
|
PIN string `json:"pin"` // if set, senders must supply it
|
||||||
NoIcons bool `json:"noIcons"` // hide Nerd Font device icons (non-NF terminals)
|
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.
|
// TLS identity for encrypted (HTTPS) mode, generated once and persisted.
|
||||||
CertPEM string `json:"certPem"`
|
CertPEM string `json:"certPem"`
|
||||||
|
|
|
||||||
|
|
@ -83,6 +83,43 @@ func New(self protocol.DeviceInfo) *Discoverer {
|
||||||
// Events returns the channel on which peer events are delivered.
|
// Events returns the channel on which peer events are delivered.
|
||||||
func (d *Discoverer) Events() <-chan Event { return d.events }
|
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
|
// SetAlias updates the advertised alias (and device model) at runtime. Call
|
||||||
// Announce afterwards to push it out immediately.
|
// Announce afterwards to push it out immediately.
|
||||||
func (d *Discoverer) SetAlias(alias string) {
|
func (d *Discoverer) SetAlias(alias string) {
|
||||||
|
|
|
||||||
56
internal/discovery/find_test.go
Normal file
56
internal/discovery/find_test.go
Normal 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
56
internal/notify/notify.go
Normal 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)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
@ -38,6 +38,7 @@ type Controller interface {
|
||||||
SetAlias(string)
|
SetAlias(string)
|
||||||
SetReceiveDir(string)
|
SetReceiveDir(string)
|
||||||
SetPIN(string)
|
SetPIN(string)
|
||||||
|
SetNotify(bool)
|
||||||
}
|
}
|
||||||
|
|
||||||
type screen int
|
type screen int
|
||||||
|
|
@ -426,6 +427,15 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
_ = m.cfg.Save()
|
_ = m.cfg.Save()
|
||||||
}
|
}
|
||||||
return m, nil
|
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":
|
case "e":
|
||||||
if m.screen == screenSettings {
|
if m.screen == screenSettings {
|
||||||
return m.beginEdit()
|
return m.beginEdit()
|
||||||
|
|
@ -1124,6 +1134,7 @@ func (m Model) settingsView() string {
|
||||||
{"Auto-accept", boolStr(m.autoAccept)},
|
{"Auto-accept", boolStr(m.autoAccept)},
|
||||||
{"PIN", boolStr(m.cfg.PIN != "")},
|
{"PIN", boolStr(m.cfg.PIN != "")},
|
||||||
{"Icons", boolStr(!m.cfg.NoIcons)},
|
{"Icons", boolStr(!m.cfg.NoIcons)},
|
||||||
|
{"Notifications", boolStr(!m.cfg.NoNotify)},
|
||||||
{"Local IPs", strings.Join(m.ips, ", ")},
|
{"Local IPs", strings.Join(m.ips, ", ")},
|
||||||
}
|
}
|
||||||
for _, r := range rows {
|
for _, r := range rows {
|
||||||
|
|
@ -1174,7 +1185,7 @@ func (m Model) footerText() string {
|
||||||
case m.screen == screenMessages:
|
case m.screen == screenMessages:
|
||||||
return "enter read · y copy · d delete · 1-5 switch · q quit"
|
return "enter read · y copy · d delete · 1-5 switch · q quit"
|
||||||
case m.screen == screenSettings:
|
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"
|
return "q quit"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue