omarchy-send/cmd/omarchy-send/main.go
28allday e66662feb9 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>
2026-05-29 14:13:43 +01:00

226 lines
7.5 KiB
Go

// Command omarchy-send is a LocalSend-compatible file-transfer client with a terminal
// UI, designed to run headless over SSH on Arch/Omarchy servers.
package main
import (
"context"
"crypto/tls"
"errors"
"flag"
"fmt"
"log"
"os"
"strings"
"sync/atomic"
"time"
tea "github.com/charmbracelet/bubbletea"
"omarchy-send/internal/app"
"omarchy-send/internal/client"
"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"
)
// controller adapts the discovery + sender + server services to tui.Controller.
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() }
func (c controller) Send(p discovery.Peer, paths []string, pin string) { c.sender.Send(p, paths, pin) }
func (c controller) SendMessage(p discovery.Peer, text, pin string) {
c.sender.SendMessage(p, text, pin)
}
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) {
c.disc.SetAlias(alias)
c.srv.SetAlias(alias)
c.sender.SetAlias(alias)
c.disc.Announce()
}
func main() {
var (
aliasFlag = flag.String("alias", "", "device alias (overrides config for this run)")
portFlag = flag.Int("port", 0, "listen port (overrides config for this run)")
dirFlag = flag.String("dir", "", "receive directory (overrides config for this run)")
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()
// The TUI owns the terminal, so keep stray stdlib logging (e.g. net/http's
// "unsolicited response on idle channel" notice) off the screen — route it
// to the debug log when enabled, otherwise discard it.
log.SetOutput(dbg.Writer())
cfg, err := config.Load()
if err != nil {
fmt.Fprintf(os.Stderr, "config: %v\n", err)
os.Exit(1)
}
if *aliasFlag != "" {
cfg.Alias = *aliasFlag
cfg.DeviceModel = *aliasFlag
}
if *portFlag != 0 {
cfg.Port = *portFlag
}
if *dirFlag != "" {
cfg.ReceiveDir = *dirFlag
}
if *pinFlag != "" {
cfg.PIN = *pinFlag
}
if *autoFlag {
cfg.AutoAccept = true
}
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()
disc := discovery.New(cfg.DeviceInfo())
var cert *tls.Certificate
if cfg.Protocol == "https" {
c, err := cfg.TLSCertificate()
if err != nil {
fmt.Fprintf(os.Stderr, "tls: %v\n", err)
os.Exit(1)
}
cert = &c
}
srv := server.New(server.Options{
Info: cfg.DeviceInfo(),
OnPeer: disc.NotePeer,
Cert: cert,
ReceiveDir: cfg.ReceiveDir,
AutoAccept: cfg.AutoAccept,
PIN: cfg.PIN,
})
if err := srv.Start(ctx); err != nil {
fmt.Fprintf(os.Stderr, "server: %v\n", err)
os.Exit(1)
}
if err := disc.Run(ctx); err != nil {
fmt.Fprintf(os.Stderr, "discovery: %v\n", err)
os.Exit(1)
}
sender := client.New(cfg.DeviceInfo())
// 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)
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
if _, err := p.Run(); err != nil {
fmt.Fprintf(os.Stderr, "tui: %v\n", err)
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
}