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.
237 lines
6.6 KiB
Go
237 lines
6.6 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"
|
|
"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()
|
|
}
|
|
}
|