Files
share/apps/ios/GoBridge/share.go
T
TZGyn decaaa2de1 Use the tzgyn relay as the default backend
The CLI and iOS app now point at share.tzgyn.dev:9009 instead of the upstream croc relay. Both were verified against the deployed relay; local relays remain available through --relay and the app's Relay setting.
2026-10-26 22:32:15 +08:00

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 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
}
// 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()
}
}