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