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>
118 lines
3.5 KiB
Go
118 lines
3.5 KiB
Go
// Package app bridges the Tea-agnostic domain layer (discovery, server, client)
|
|
// into Bubble Tea messages. Domain components emit on Go channels; a bridge
|
|
// goroutine forwards them via tea.Program.Send.
|
|
package app
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
|
|
"omarchy-send/internal/discovery"
|
|
"omarchy-send/internal/server"
|
|
"omarchy-send/internal/transfer"
|
|
)
|
|
|
|
// PeerFoundMsg is delivered when a peer is discovered or its address changes.
|
|
type PeerFoundMsg struct{ Peer discovery.Peer }
|
|
|
|
// PeerLostMsg is delivered when a peer ages out (post-M1).
|
|
type PeerLostMsg struct{ Fingerprint string }
|
|
|
|
// IncomingMsg is delivered when a peer asks to send us files. The TUI shows an
|
|
// accept prompt and answers via the carried Reply channel.
|
|
type IncomingMsg struct{ Req server.AcceptRequest }
|
|
|
|
// TransferMsg reports progress/lifecycle of a transfer (either direction).
|
|
type TransferMsg struct{ Ev transfer.Event }
|
|
|
|
// MessageMsg is delivered when a peer sends us a plain-text message.
|
|
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. 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)) {
|
|
go func() {
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case ev := <-transfers:
|
|
send(TransferMsg{Ev: ev})
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
|
|
// BridgeDiscovery forwards discovery events to the Tea program until ctx is
|
|
// cancelled. send is typically tea.Program.Send.
|
|
func BridgeDiscovery(ctx context.Context, events <-chan discovery.Event, send func(tea.Msg)) {
|
|
go func() {
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case ev := <-events:
|
|
switch ev.Kind {
|
|
case discovery.PeerFound:
|
|
send(PeerFoundMsg{Peer: ev.Peer})
|
|
case discovery.PeerLost:
|
|
send(PeerLostMsg{Fingerprint: ev.Peer.Info.Fingerprint})
|
|
}
|
|
}
|
|
}
|
|
}()
|
|
}
|