Add a native iOS client for relay-based file sharing
The SwiftUI app bundles the Go swarm engine through gomobile, so an iPhone is a real peer using the existing PAKE handshake, chunk verification, and relay fallback. XcodeGen builds the project and devicectl installs it on the paired phone.
This commit is contained in:
@@ -0,0 +1,236 @@
|
||||
// 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"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
|
||||
"github.com/tzgyn/share/src/swarm"
|
||||
"github.com/tzgyn/share/src/utils"
|
||||
)
|
||||
|
||||
// DefaultRelay is the public relay the CLI uses when the user does not
|
||||
// pass --relay. The iOS app lets the user override it (for example with a
|
||||
// relay running on their Mac's LAN address).
|
||||
const DefaultRelay = "croc.schollz.com: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
|
||||
}
|
||||
|
||||
// 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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user