The relay now answers a stateless ping with pong. The Relay card auto-checks on appear and address changes, showing latency or the connection error; this verifies the endpoint speaks the share protocol, not just that a TCP port is open.
331 lines
9.1 KiB
Go
331 lines
9.1 KiB
Go
// Package share is the gomobile bridge between the native iOS app and
|
|
// the Go swarm engine in this repository.
|
|
//
|
|
// gomobile cannot expose every Go type to Swift, so this package keeps the
|
|
// boundary deliberately narrow: Swift owns the screens and file pickers,
|
|
// while this package owns the one long-lived swarm.Client for the room on
|
|
// screen. Swift calls Start once, polls StatusJSON for progress, and calls
|
|
// Stop when the user leaves the room.
|
|
package share
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"net"
|
|
"os"
|
|
"path/filepath"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/tzgyn/share/src/comm"
|
|
"github.com/tzgyn/share/src/swarm"
|
|
"github.com/tzgyn/share/src/utils"
|
|
)
|
|
|
|
// DefaultRelay is the project's own relay, used by both the CLI and the
|
|
// iOS app when the user does not override it. Point this at a Mac's LAN
|
|
// address instead if you want to test with a local `share relay`.
|
|
const DefaultRelay = "share.tzgyn.dev:9009"
|
|
|
|
// status is the JSON shape the Swift app decodes from StatusJSON. It
|
|
// mirrors swarm.RoomStatus but adds the session fields that live only on
|
|
// this bridge: whether the room is still running, which relay it used,
|
|
// and the last fatal error, if any. It stays unexported because Swift
|
|
// reads the JSON rather than this Go type directly.
|
|
type status struct {
|
|
Code string `json:"code"`
|
|
Relay string `json:"relay"`
|
|
OutDir string `json:"outDir"`
|
|
Running bool `json:"running"`
|
|
Peers int `json:"peers"`
|
|
Files []swarm.FileStatus `json:"files"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
// Session is one room, owned by the iOS app. A Session is not safe to
|
|
// reuse for two rooms at once; Start refuses while another room is live.
|
|
// That is intentional: the phone UI has one Send/Receive sheet at a time,
|
|
// and one Client means one set of progress counters to display.
|
|
type Session struct {
|
|
mu sync.Mutex
|
|
client *swarm.Client
|
|
code string
|
|
relay string
|
|
outDir string
|
|
running bool
|
|
lastErr string
|
|
generation uint64
|
|
lastRoom status // retained after the room ends, so the final 100% is visible
|
|
}
|
|
|
|
// NewSession creates an idle session. The Swift side holds exactly one of
|
|
// these for the lifetime of the app.
|
|
func NewSession() *Session {
|
|
return &Session{}
|
|
}
|
|
|
|
// GenerateCode returns a new human-readable room code in the same
|
|
// "1234-word-word-word-word" shape the CLI prints. Both sides must type
|
|
// the same code to derive the same handshake secret.
|
|
func GenerateCode() string {
|
|
return utils.GetRandomName()
|
|
}
|
|
|
|
// RelayDefault returns the relay address used when the app's relay field
|
|
// is empty. It is a function rather than a bare constant because gomobile
|
|
// only generates callable symbols for exported functions and methods.
|
|
func RelayDefault() string {
|
|
return DefaultRelay
|
|
}
|
|
|
|
// relayStatus is the JSON result of CheckRelay. Swift shows it as a small
|
|
// "backend reachable" indicator in the Relay card.
|
|
type relayStatus struct {
|
|
OK bool `json:"ok"`
|
|
Relay string `json:"relay"`
|
|
LatencyMs int64 `json:"latencyMs"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
// CheckRelay probes the configured relay with the protocol's stateless
|
|
// ping/pong exchange. A plain TCP connect would only prove that something
|
|
// is listening on the port; this also proves the service actually speaks
|
|
// the share relay protocol.
|
|
//
|
|
// The UI calls this in the background. The five-second dial and read
|
|
// timeouts keep a dead backend from leaving the indicator spinning.
|
|
func CheckRelay(address string) string {
|
|
relay := normalizeRelay(address)
|
|
result := relayStatus{Relay: relay}
|
|
start := time.Now()
|
|
|
|
c, err := comm.NewConnection(relay, 5*time.Second)
|
|
if err != nil {
|
|
result.Error = err.Error()
|
|
return marshalRelayStatus(result)
|
|
}
|
|
defer c.Close()
|
|
|
|
if err := c.Send([]byte(`{"type":"ping"}`)); err != nil {
|
|
result.Error = err.Error()
|
|
return marshalRelayStatus(result)
|
|
}
|
|
|
|
// comm's normal read deadline is measured in hours. Run the read in a
|
|
// goroutine so a relay that accepts but never answers cannot leave the
|
|
// UI spinning; the deferred Close below unblocks the goroutine.
|
|
type readResult struct {
|
|
data []byte
|
|
err error
|
|
}
|
|
read := make(chan readResult, 1)
|
|
go func() {
|
|
data, err := c.Receive()
|
|
read <- readResult{data: data, err: err}
|
|
}()
|
|
|
|
var response []byte
|
|
select {
|
|
case r := <-read:
|
|
if r.err != nil {
|
|
result.Error = r.err.Error()
|
|
return marshalRelayStatus(result)
|
|
}
|
|
response = r.data
|
|
case <-time.After(5 * time.Second):
|
|
result.Error = "timed out waiting for relay response"
|
|
return marshalRelayStatus(result)
|
|
}
|
|
|
|
var pong struct {
|
|
Type string `json:"type"`
|
|
}
|
|
if err := json.Unmarshal(response, &pong); err != nil || pong.Type != "pong" {
|
|
result.Error = "relay did not answer with pong"
|
|
return marshalRelayStatus(result)
|
|
}
|
|
|
|
result.OK = true
|
|
result.LatencyMs = time.Since(start).Milliseconds()
|
|
return marshalRelayStatus(result)
|
|
}
|
|
|
|
func normalizeRelay(address string) string {
|
|
if address == "" {
|
|
address = DefaultRelay
|
|
}
|
|
host, port, err := net.SplitHostPort(address)
|
|
if err != nil {
|
|
return net.JoinHostPort(address, "9009")
|
|
}
|
|
return net.JoinHostPort(host, port)
|
|
}
|
|
|
|
func marshalRelayStatus(result relayStatus) string {
|
|
b, err := json.Marshal(result)
|
|
if err != nil {
|
|
return `{"ok":false,"error":"could not encode relay status"}`
|
|
}
|
|
return string(b)
|
|
}
|
|
|
|
// Start joins one room. filesJSON is a JSON array of absolute paths inside
|
|
// the app sandbox; pass "[]" to be a pure receiver. outDir is created if
|
|
// needed and must also be absolute. oneTime stops the room as soon as every
|
|
// file currently known is complete, which is useful for a receiver that
|
|
// does not want to keep seeding in the background.
|
|
//
|
|
// Start returns as soon as the relay join succeeds; progress is read with
|
|
// StatusJSON. The room runs in a goroutine until Stop or oneTime
|
|
// completion.
|
|
func (s *Session) Start(relay, code, filesJSON, outDir string, oneTime bool) error {
|
|
s.mu.Lock()
|
|
if s.client != nil {
|
|
s.mu.Unlock()
|
|
return fmt.Errorf("a room is already active; stop it before starting another")
|
|
}
|
|
s.mu.Unlock()
|
|
|
|
var files []string
|
|
if filesJSON != "" {
|
|
if err := json.Unmarshal([]byte(filesJSON), &files); err != nil {
|
|
return fmt.Errorf("invalid file list: %w", err)
|
|
}
|
|
}
|
|
|
|
if relay == "" {
|
|
relay = DefaultRelay
|
|
}
|
|
if code == "" {
|
|
code = GenerateCode()
|
|
}
|
|
if outDir == "" {
|
|
return fmt.Errorf("output folder is required")
|
|
}
|
|
if !filepath.IsAbs(outDir) {
|
|
return fmt.Errorf("output folder must be an absolute path: %s", outDir)
|
|
}
|
|
for _, file := range files {
|
|
if !filepath.IsAbs(file) {
|
|
return fmt.Errorf("file path must be absolute: %s", file)
|
|
}
|
|
}
|
|
if err := os.MkdirAll(outDir, 0o755); err != nil {
|
|
return fmt.Errorf("could not create output folder: %w", err)
|
|
}
|
|
|
|
cl, err := swarm.Start(swarm.Options{
|
|
RelayAddress: relay,
|
|
Code: code,
|
|
Files: files,
|
|
OutDir: outDir,
|
|
OneTime: oneTime,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
s.mu.Lock()
|
|
s.generation++
|
|
generation := s.generation
|
|
s.client = cl
|
|
s.code = code
|
|
s.relay = relay
|
|
s.outDir = outDir
|
|
s.running = true
|
|
s.lastErr = ""
|
|
s.lastRoom = status{
|
|
Code: code,
|
|
Relay: relay,
|
|
OutDir: outDir,
|
|
Running: true,
|
|
Files: make([]swarm.FileStatus, 0),
|
|
}
|
|
s.mu.Unlock()
|
|
|
|
// Wait is the only way to learn that the relay connection died. Do it
|
|
// off the main thread and fold the final Client.Status into lastRoom
|
|
// before releasing client, so a finished one-time transfer still shows
|
|
// its completed files after the room stops. The generation check keeps
|
|
// a stopped room's late Wait from clobbering a room the user started
|
|
// immediately afterwards.
|
|
go func() {
|
|
waitErr := cl.Wait()
|
|
final := cl.Status()
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.generation != generation {
|
|
return
|
|
}
|
|
s.running = false
|
|
s.client = nil
|
|
if waitErr != nil {
|
|
s.lastErr = waitErr.Error()
|
|
}
|
|
s.lastRoom = status{
|
|
Code: s.code,
|
|
Relay: s.relay,
|
|
OutDir: s.outDir,
|
|
Running: false,
|
|
Peers: final.Peers,
|
|
Files: final.Files,
|
|
Error: s.lastErr,
|
|
}
|
|
}()
|
|
|
|
return nil
|
|
}
|
|
|
|
// StatusJSON serializes the room's current state for Swift. JSON at the
|
|
// boundary keeps the gomobile surface to strings, booleans, and []string
|
|
// instead of making every nested progress type a generated Swift class.
|
|
func (s *Session) StatusJSON() string {
|
|
s.mu.Lock()
|
|
cl := s.client
|
|
room := s.lastRoom
|
|
running := s.running
|
|
lastErr := s.lastErr
|
|
code, relay, outDir := s.code, s.relay, s.outDir
|
|
s.mu.Unlock()
|
|
|
|
if cl != nil {
|
|
live := cl.Status()
|
|
room = status{
|
|
Code: code,
|
|
Relay: relay,
|
|
OutDir: outDir,
|
|
Running: running,
|
|
Peers: live.Peers,
|
|
Files: live.Files,
|
|
Error: lastErr,
|
|
}
|
|
} else {
|
|
room.Running = false
|
|
room.Error = lastErr
|
|
}
|
|
if room.Files == nil {
|
|
room.Files = make([]swarm.FileStatus, 0)
|
|
}
|
|
|
|
b, err := json.Marshal(room)
|
|
if err != nil {
|
|
return `{"error":"could not encode status"}`
|
|
}
|
|
return string(b)
|
|
}
|
|
|
|
// Stop leaves the current room. It is safe to call when no room is
|
|
// running; the UI uses that on disappear and when the app returns to the
|
|
// foreground to avoid a stale relay connection.
|
|
func (s *Session) Stop() {
|
|
s.mu.Lock()
|
|
cl := s.client
|
|
s.client = nil
|
|
s.running = false
|
|
s.mu.Unlock()
|
|
|
|
if cl != nil {
|
|
cl.Stop()
|
|
}
|
|
}
|