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,24 @@
|
||||
module github.com/tzgyn/share/apps/ios/GoBridge
|
||||
|
||||
go 1.26.0
|
||||
|
||||
tool golang.org/x/mobile/cmd/gobind
|
||||
|
||||
require github.com/tzgyn/share v0.0.0
|
||||
|
||||
require (
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/kalafut/imohash v1.1.1 // indirect
|
||||
github.com/magisterquis/connectproxy v0.0.0-20200725203833-3582e84f0c9b // indirect
|
||||
github.com/minio/highwayhash v1.0.4 // indirect
|
||||
github.com/twmb/murmur3 v1.1.8 // indirect
|
||||
golang.org/x/crypto v0.58.0 // indirect
|
||||
golang.org/x/mobile v0.0.0-20261011034528-197770444867 // indirect
|
||||
golang.org/x/mod v0.42.0 // indirect
|
||||
golang.org/x/net v0.61.0 // indirect
|
||||
golang.org/x/sync v0.24.0 // indirect
|
||||
golang.org/x/sys v0.49.0 // indirect
|
||||
golang.org/x/tools v0.52.0 // indirect
|
||||
)
|
||||
|
||||
replace github.com/tzgyn/share => ../../..
|
||||
@@ -0,0 +1,32 @@
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/kalafut/imohash v1.1.1 h1:G/HYtKgteQSVU96LidSJEbUGoZOMiBcuXYxbeb2W9e4=
|
||||
github.com/kalafut/imohash v1.1.1/go.mod h1:6cn9lU0Sj8M4eu9UaQm1kR/5y3k/ayB68yntRhGloL4=
|
||||
github.com/magisterquis/connectproxy v0.0.0-20200725203833-3582e84f0c9b h1:xZ59n7Frzh8CwyfAapUZLSg+gXH5m63YEaFCMpDHhpI=
|
||||
github.com/magisterquis/connectproxy v0.0.0-20200725203833-3582e84f0c9b/go.mod h1:uDd4sYVYsqcxAB8j+Q7uhL6IJCs/r1kxib1HV4bgOMg=
|
||||
github.com/minio/highwayhash v1.0.4 h1:asJizugGgchQod2ja9NJlGOWq4s7KsAWr5XUc9Clgl4=
|
||||
github.com/minio/highwayhash v1.0.4/go.mod h1:GGYsuwP/fPD6Y9hMiXuapVvlIUEhFhMTh0rxU3ik1LQ=
|
||||
github.com/twmb/murmur3 v1.1.5/go.mod h1:Qq/R7NUyOfr65zD+6Q5IHKsJLwP7exErjN6lyyq3OSQ=
|
||||
github.com/twmb/murmur3 v1.1.8 h1:8Yt9taO/WN3l08xErzjeschgZU2QSrwm1kclYq+0aRg=
|
||||
github.com/twmb/murmur3 v1.1.8/go.mod h1:Qq/R7NUyOfr65zD+6Q5IHKsJLwP7exErjN6lyyq3OSQ=
|
||||
golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54=
|
||||
golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w=
|
||||
golang.org/x/mobile v0.0.0-20261011034528-197770444867 h1:rav73cdd7OQUPgt1aIFbZQTiPNnjfueKXvfOiLCY0Ss=
|
||||
golang.org/x/mobile v0.0.0-20261011034528-197770444867/go.mod h1:HR/iBzuQmVTl/us733QPwrqHp43a8m9ZLgyKXD3ST6U=
|
||||
golang.org/x/mod v0.42.0 h1:ICeL3t2L30ljSPHHxDnR4fqVcBt/zR2zJAb9juavmQg=
|
||||
golang.org/x/mod v0.42.0/go.mod h1:u5fzIrRInkL8MaO1cbRkUHT64v303YIT5KEqpNr7jRs=
|
||||
golang.org/x/net v0.61.0 h1:RR+6j/BTBCrggOLugxa+66R8FvoN6MHlzDlZIbN9YFw=
|
||||
golang.org/x/net v0.61.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI=
|
||||
golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA=
|
||||
golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
|
||||
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
|
||||
golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||
golang.org/x/tools v0.52.0 h1:ryxg4U63oaUYk7uOLTF37n43f4qgtGvqV4Ioit1BaEs=
|
||||
golang.org/x/tools v0.52.0/go.mod h1:ItcP5UNaVGG+Yc4eIYmaFLNJ4soEopkx5Y2d4lcEurs=
|
||||
golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnpsEAGHaNy0n/rJM=
|
||||
golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY=
|
||||
golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated h1:1h2MnaIAIXISqTFKdENegdpAgUXz6NrPEsbIeWaBRvM=
|
||||
golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated/go.mod h1:RVAQXBGNv1ib0J382/DPCRS/BPnsGebyM1Gj5VSDpG8=
|
||||
@@ -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