Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf5d19900c | ||
|
|
decaaa2de1 | ||
|
|
9a37e93cfc |
@@ -0,0 +1,14 @@
|
||||
# XcodeGen output; project.yml is the source of truth.
|
||||
Share.xcodeproj/
|
||||
*.xcworkspace
|
||||
|
||||
# Build output
|
||||
build/
|
||||
DerivedData/
|
||||
xcuserdata/
|
||||
*.xcuserstate
|
||||
|
||||
# gomobile output and local toolchain
|
||||
Share.xcframework/
|
||||
GoBridge/Share.xcframework/
|
||||
.build/
|
||||
@@ -0,0 +1,173 @@
|
||||
#!/usr/bin/env swift
|
||||
// Draws the 1024x1024 Share app icon. Run from apps/ios:
|
||||
// swift Branding/make-icon.swift Share/Assets.xcassets/AppIcon.appiconset/AppIcon.png
|
||||
import AppKit
|
||||
import CoreGraphics
|
||||
import ImageIO
|
||||
|
||||
let size = 1024
|
||||
let output = CommandLine.arguments.count > 1
|
||||
? CommandLine.arguments[1]
|
||||
: "Share/Assets.xcassets/AppIcon.appiconset/AppIcon.png"
|
||||
|
||||
func color(_ hex: UInt32, _ alpha: CGFloat = 1) -> NSColor {
|
||||
NSColor(
|
||||
calibratedRed: CGFloat((hex >> 16) & 0xFF) / 255,
|
||||
green: CGFloat((hex >> 8) & 0xFF) / 255,
|
||||
blue: CGFloat(hex & 0xFF) / 255,
|
||||
alpha: alpha
|
||||
)
|
||||
}
|
||||
|
||||
guard let rep = NSBitmapImageRep(
|
||||
bitmapDataPlanes: nil,
|
||||
pixelsWide: size,
|
||||
pixelsHigh: size,
|
||||
bitsPerSample: 8,
|
||||
samplesPerPixel: 4,
|
||||
hasAlpha: true,
|
||||
isPlanar: false,
|
||||
colorSpaceName: .deviceRGB,
|
||||
bytesPerRow: 0,
|
||||
bitsPerPixel: 0
|
||||
) else {
|
||||
fatalError("Could not create bitmap")
|
||||
}
|
||||
|
||||
NSGraphicsContext.saveGraphicsState()
|
||||
NSGraphicsContext.current = NSGraphicsContext(bitmapImageRep: rep)
|
||||
NSGraphicsContext.current?.imageInterpolation = .high
|
||||
NSGraphicsContext.current?.shouldAntialias = true
|
||||
|
||||
let canvas = NSRect(x: 0, y: 0, width: size, height: size)
|
||||
let background = NSGradient(colors: [color(0x07111F), color(0x0B3B36)])!
|
||||
background.draw(in: canvas, angle: -55)
|
||||
|
||||
// Soft teal glow in the upper-left and blue glow in the lower-right.
|
||||
let tealGlow = NSGradient(colors: [color(0x5EEAD4, 0.35), color(0x5EEAD4, 0)])!
|
||||
tealGlow.draw(
|
||||
in: NSBezierPath(ovalIn: NSRect(x: -260, y: 430, width: 1020, height: 1020)),
|
||||
relativeCenterPosition: NSPoint(x: 0, y: 0)
|
||||
)
|
||||
let blueGlow = NSGradient(colors: [color(0x60A5FA, 0.28), color(0x60A5FA, 0)])!
|
||||
blueGlow.draw(
|
||||
in: NSBezierPath(ovalIn: NSRect(x: 300, y: -300, width: 980, height: 980)),
|
||||
relativeCenterPosition: NSPoint(x: 0, y: 0)
|
||||
)
|
||||
|
||||
// A barely-there inset panel gives the flat icon some depth.
|
||||
let panel = NSBezierPath(
|
||||
roundedRect: NSRect(x: 116, y: 116, width: 792, height: 792),
|
||||
xRadius: 180,
|
||||
yRadius: 180
|
||||
)
|
||||
color(0xFFFFFF, 0.035).setFill()
|
||||
panel.fill()
|
||||
color(0xFFFFFF, 0.07).setStroke()
|
||||
panel.lineWidth = 2
|
||||
panel.stroke()
|
||||
|
||||
func strokeArrow(shaftFrom: NSPoint, shaftTo: NSPoint,
|
||||
headLeft: NSPoint, headTip: NSPoint, headRight: NSPoint,
|
||||
tint: NSColor) {
|
||||
let shadow = NSShadow()
|
||||
shadow.shadowColor = color(0x000000, 0.3)
|
||||
shadow.shadowBlurRadius = 34
|
||||
shadow.shadowOffset = NSSize(width: 0, height: -16)
|
||||
shadow.set()
|
||||
|
||||
tint.setStroke()
|
||||
let shaft = NSBezierPath()
|
||||
shaft.move(to: shaftFrom)
|
||||
shaft.line(to: shaftTo)
|
||||
shaft.lineWidth = 74
|
||||
shaft.lineCapStyle = .round
|
||||
shaft.stroke()
|
||||
|
||||
let head = NSBezierPath()
|
||||
head.move(to: headLeft)
|
||||
head.line(to: headTip)
|
||||
head.line(to: headRight)
|
||||
head.lineWidth = 78
|
||||
head.lineCapStyle = .round
|
||||
head.lineJoinStyle = .round
|
||||
head.stroke()
|
||||
}
|
||||
|
||||
// Up arrow on the left.
|
||||
strokeArrow(
|
||||
shaftFrom: NSPoint(x: 350, y: 288),
|
||||
shaftTo: NSPoint(x: 350, y: 610),
|
||||
headLeft: NSPoint(x: 238, y: 490),
|
||||
headTip: NSPoint(x: 350, y: 640),
|
||||
headRight: NSPoint(x: 462, y: 490),
|
||||
tint: color(0xF4FFFC)
|
||||
)
|
||||
|
||||
// Down arrow on the right.
|
||||
strokeArrow(
|
||||
shaftFrom: NSPoint(x: 674, y: 736),
|
||||
shaftTo: NSPoint(x: 674, y: 414),
|
||||
headLeft: NSPoint(x: 562, y: 534),
|
||||
headTip: NSPoint(x: 674, y: 384),
|
||||
headRight: NSPoint(x: 786, y: 534),
|
||||
tint: color(0x5EEAD4)
|
||||
)
|
||||
|
||||
// Three small dots suggest a swarm: peers nearby, not a central server.
|
||||
for (point, alpha) in [
|
||||
(NSPoint(x: 250, y: 790), CGFloat(0.55)),
|
||||
(NSPoint(x: 512, y: 840), CGFloat(0.35)),
|
||||
(NSPoint(x: 790, y: 260), CGFloat(0.45)),
|
||||
] {
|
||||
let dot = NSBezierPath(ovalIn: NSRect(x: point.x - 7, y: point.y - 7, width: 14, height: 14))
|
||||
color(0xFFFFFF, alpha).setFill()
|
||||
dot.fill()
|
||||
}
|
||||
|
||||
NSGraphicsContext.restoreGraphicsState()
|
||||
|
||||
guard let cgImage = rep.cgImage else {
|
||||
fatalError("Could not make a CGImage")
|
||||
}
|
||||
|
||||
// App Store icons must not contain an alpha channel. Drawing the rendered
|
||||
// image into an opaque context and then encoding that image gives us an
|
||||
// RGB PNG without changing a single visible pixel.
|
||||
let width = cgImage.width
|
||||
let height = cgImage.height
|
||||
let colorSpace = CGColorSpaceCreateDeviceRGB()
|
||||
guard let context = CGContext(
|
||||
data: nil,
|
||||
width: width,
|
||||
height: height,
|
||||
bitsPerComponent: 8,
|
||||
bytesPerRow: 0,
|
||||
space: colorSpace,
|
||||
bitmapInfo: CGImageAlphaInfo.noneSkipLast.rawValue
|
||||
) else {
|
||||
fatalError("Could not create an opaque context")
|
||||
}
|
||||
context.draw(cgImage, in: CGRect(x: 0, y: 0, width: width, height: height))
|
||||
guard let opaque = context.makeImage() else {
|
||||
fatalError("Could not render the opaque icon")
|
||||
}
|
||||
|
||||
let url = URL(fileURLWithPath: output)
|
||||
try FileManager.default.createDirectory(
|
||||
at: url.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
guard let destination = CGImageDestinationCreateWithURL(
|
||||
url as CFURL,
|
||||
"public.png" as CFString,
|
||||
1,
|
||||
nil
|
||||
) else {
|
||||
fatalError("Could not create a PNG destination")
|
||||
}
|
||||
CGImageDestinationAddImage(destination, opaque, nil)
|
||||
guard CGImageDestinationFinalize(destination) else {
|
||||
fatalError("Could not write the PNG")
|
||||
}
|
||||
print("Wrote \(url.path)")
|
||||
@@ -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,330 @@
|
||||
// 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"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/tzgyn/share/src/comm"
|
||||
"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
|
||||
}
|
||||
|
||||
// relayStatus is the JSON result of CheckRelay. Swift shows it as a small
|
||||
// "backend reachable" indicator in the Relay card.
|
||||
type relayStatus struct {
|
||||
OK bool `json:"ok"`
|
||||
Relay string `json:"relay"`
|
||||
LatencyMs int64 `json:"latencyMs"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// CheckRelay probes the configured relay with the protocol's stateless
|
||||
// ping/pong exchange. A plain TCP connect would only prove that something
|
||||
// is listening on the port; this also proves the service actually speaks
|
||||
// the share relay protocol.
|
||||
//
|
||||
// The UI calls this in the background. The five-second dial and read
|
||||
// timeouts keep a dead backend from leaving the indicator spinning.
|
||||
func CheckRelay(address string) string {
|
||||
relay := normalizeRelay(address)
|
||||
result := relayStatus{Relay: relay}
|
||||
start := time.Now()
|
||||
|
||||
c, err := comm.NewConnection(relay, 5*time.Second)
|
||||
if err != nil {
|
||||
result.Error = err.Error()
|
||||
return marshalRelayStatus(result)
|
||||
}
|
||||
defer c.Close()
|
||||
|
||||
if err := c.Send([]byte(`{"type":"ping"}`)); err != nil {
|
||||
result.Error = err.Error()
|
||||
return marshalRelayStatus(result)
|
||||
}
|
||||
|
||||
// comm's normal read deadline is measured in hours. Run the read in a
|
||||
// goroutine so a relay that accepts but never answers cannot leave the
|
||||
// UI spinning; the deferred Close below unblocks the goroutine.
|
||||
type readResult struct {
|
||||
data []byte
|
||||
err error
|
||||
}
|
||||
read := make(chan readResult, 1)
|
||||
go func() {
|
||||
data, err := c.Receive()
|
||||
read <- readResult{data: data, err: err}
|
||||
}()
|
||||
|
||||
var response []byte
|
||||
select {
|
||||
case r := <-read:
|
||||
if r.err != nil {
|
||||
result.Error = r.err.Error()
|
||||
return marshalRelayStatus(result)
|
||||
}
|
||||
response = r.data
|
||||
case <-time.After(5 * time.Second):
|
||||
result.Error = "timed out waiting for relay response"
|
||||
return marshalRelayStatus(result)
|
||||
}
|
||||
|
||||
var pong struct {
|
||||
Type string `json:"type"`
|
||||
}
|
||||
if err := json.Unmarshal(response, &pong); err != nil || pong.Type != "pong" {
|
||||
result.Error = "relay did not answer with pong"
|
||||
return marshalRelayStatus(result)
|
||||
}
|
||||
|
||||
result.OK = true
|
||||
result.LatencyMs = time.Since(start).Milliseconds()
|
||||
return marshalRelayStatus(result)
|
||||
}
|
||||
|
||||
func normalizeRelay(address string) string {
|
||||
if address == "" {
|
||||
address = DefaultRelay
|
||||
}
|
||||
host, port, err := net.SplitHostPort(address)
|
||||
if err != nil {
|
||||
return net.JoinHostPort(address, "9009")
|
||||
}
|
||||
return net.JoinHostPort(host, port)
|
||||
}
|
||||
|
||||
func marshalRelayStatus(result relayStatus) string {
|
||||
b, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
return `{"ok":false,"error":"could not encode relay status"}`
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// 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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
# Share for iOS
|
||||
|
||||
A native SwiftUI client for the `share` swarm. It uses the same Go engine as
|
||||
the CLI, compiled for iOS with gomobile, so the phone is a real peer: it can
|
||||
seed files, receive files, verify chunks, and fall back to the relay pipe when
|
||||
a direct connection is blocked.
|
||||
|
||||
```
|
||||
apps/ios/
|
||||
project.yml XcodeGen project (source of truth)
|
||||
Share/ SwiftUI sources, assets, Info.plist
|
||||
GoBridge/ nested Go module exposing swarm to Swift
|
||||
build-go.sh builds Share.xcframework with gomobile
|
||||
build.sh simulator or signed device build
|
||||
run-device.sh install + launch on a paired iPhone
|
||||
Branding/make-icon.swift regenerates the app icon
|
||||
```
|
||||
|
||||
## Build
|
||||
|
||||
Requirements: Xcode, `xcodegen`, Go 1.26+ for the gomobile bridge, and
|
||||
network access the first time `gomobile` is installed. (The CLI itself still
|
||||
builds with the repo's Go 1.25 requirement.)
|
||||
|
||||
```sh
|
||||
cd apps/ios
|
||||
|
||||
# Simulator (Apple Silicon)
|
||||
./build.sh simulator
|
||||
|
||||
# Signed device build (team RX6767S6BW)
|
||||
./build.sh device
|
||||
|
||||
# Install on the paired iPhone over Wi-Fi
|
||||
./run-device.sh FeelsCringeMan
|
||||
```
|
||||
|
||||
`build.sh` regenerates `Share.xcframework` before running `xcodegen generate`;
|
||||
the XCFramework and the generated `.xcodeproj` are build artifacts and are
|
||||
ignored by git.
|
||||
|
||||
## Smoke tests
|
||||
|
||||
The app has two launch-argument driven tests that write
|
||||
`Documents/share-selftest.txt`; console output is unreliable on a simulator
|
||||
and unavailable on a device.
|
||||
|
||||
Receive (the app is the peer that downloads):
|
||||
|
||||
```sh
|
||||
# 1. Start a relay on the Mac (all interfaces, so a phone can reach it).
|
||||
go run . relay --host 0.0.0.0 --port 9009
|
||||
|
||||
# 2. Launch the test. Use 127.0.0.1 for the simulator, or the Mac's LAN
|
||||
# address for a physical phone.
|
||||
xcrun simctl launch <udid> dev.tzgyn.share \
|
||||
-shareSelfTest -shareSelfTestRelay 127.0.0.1:9009 \
|
||||
-shareSelfTestCode 1234-selftest-share-apple
|
||||
|
||||
# 3. Seed a file from the CLI.
|
||||
go run . send --relay 127.0.0.1:9009 --code 1234-selftest-share-apple /tmp/test.bin
|
||||
|
||||
# 4. Read the report from the simulator container.
|
||||
DATA=$(xcrun simctl get_app_container <udid> dev.tzgyn.share data)
|
||||
cat "$DATA/Documents/share-selftest.txt"
|
||||
```
|
||||
|
||||
Send (the app seeds a generated 2 MiB file):
|
||||
|
||||
```sh
|
||||
xcrun simctl launch <udid> dev.tzgyn.share \
|
||||
-shareSelfTestSend -shareSelfTestRelay 127.0.0.1:9009 \
|
||||
-shareSelfTestCode 1234-selftest-send-apple
|
||||
|
||||
go run . --relay 127.0.0.1:9009 --out /tmp/received --one-time \
|
||||
1234-selftest-send-apple
|
||||
```
|
||||
|
||||
On a physical phone, read the report with:
|
||||
|
||||
```sh
|
||||
xcrun devicectl device copy from --device <device> \
|
||||
--domain-type appDataContainer --domain-identifier dev.tzgyn.share \
|
||||
--source Documents/share-selftest.txt --destination /tmp/share-selftest.txt
|
||||
```
|
||||
|
||||
## How it works
|
||||
|
||||
- `GoBridge/share.go` keeps one `swarm.Client` per room and exposes
|
||||
`Start`, `StatusJSON`, and `Stop`. Progress crosses the gomobile boundary as
|
||||
JSON so Swift only decodes plain value types.
|
||||
- The app copies picked files into `Documents/Seeds/` before starting a room:
|
||||
the document picker's security-scoped URL is only valid while the picker
|
||||
callback is on the stack, not for the lifetime of a transfer.
|
||||
- Received files are written to `Documents/Received/`. `UIFileSharingEnabled`
|
||||
makes that folder visible in Files → On My iPhone → Share.
|
||||
- The default relay is the project's own `share.tzgyn.dev:9009`; the CLI and
|
||||
the iOS app both use it. For local transfers, run `share relay` on a Mac and
|
||||
enter its LAN address (`192.168.x.x:9009`) under Relay in the app.
|
||||
- The Relay card probes the backend with a stateless protocol ping/pong and
|
||||
shows the latency or the connection error; it re-checks when the address
|
||||
changes and can be refreshed manually.
|
||||
|
||||
## Notes
|
||||
|
||||
- The app runs one room at a time; starting another requires stopping the
|
||||
current one.
|
||||
- Receiving uses `oneTime: true`, so the room stops after every file known at
|
||||
that moment is complete. Sending keeps seeding until Stop.
|
||||
- iOS can suspend the app when it leaves the foreground; if the relay
|
||||
connection dies, the room card shows the error and a new room can be started.
|
||||
@@ -0,0 +1,356 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
@preconcurrency import Share
|
||||
|
||||
/// The app's single source of truth. One Go session is alive at a time;
|
||||
/// Swift polls its JSON status on the main run loop and publishes plain
|
||||
/// value types to the views, so no gomobile object ever reaches SwiftUI.
|
||||
@MainActor
|
||||
final class AppModel: ObservableObject {
|
||||
@Published var mode: RoomMode = .send
|
||||
@Published var status: RoomStatus?
|
||||
@Published var pickedFiles: [PickedFile] = []
|
||||
@Published var code: String = ShareGenerateCode()
|
||||
@Published var relay: String = UserDefaults.standard.string(forKey: Keys.relay)
|
||||
?? ShareRelayDefault() {
|
||||
didSet { UserDefaults.standard.set(relay, forKey: Keys.relay) }
|
||||
}
|
||||
@Published var isBusy = false
|
||||
@Published var isImporting = false
|
||||
@Published var errorMessage: String?
|
||||
@Published var notice: String?
|
||||
@Published var showAdvanced = false
|
||||
@Published var relayCheck: RelayCheck?
|
||||
@Published var isCheckingRelay = false
|
||||
|
||||
private let session: ShareSession = ShareNewSession()!
|
||||
private let receivedDir: URL
|
||||
private let seedsDir: URL
|
||||
private var pollTimer: Timer?
|
||||
private var noticeTask: Task<Void, Never>?
|
||||
private var relayCheckTask: Task<Void, Never>?
|
||||
private var relayCheckGeneration = 0
|
||||
|
||||
private enum Keys {
|
||||
static let relay = "share.relay"
|
||||
}
|
||||
|
||||
init() {
|
||||
let documents = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
|
||||
receivedDir = documents.appendingPathComponent("Received", isDirectory: true)
|
||||
seedsDir = documents.appendingPathComponent("Seeds", isDirectory: true)
|
||||
try? FileManager.default.createDirectory(at: receivedDir, withIntermediateDirectories: true)
|
||||
try? FileManager.default.createDirectory(at: seedsDir, withIntermediateDirectories: true)
|
||||
refresh()
|
||||
}
|
||||
|
||||
// MARK: - Setup state
|
||||
|
||||
var hasRoom: Bool { status != nil }
|
||||
|
||||
var trimmedCode: String {
|
||||
code.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
var canStartSend: Bool {
|
||||
!pickedFiles.isEmpty && !isBusy && !hasRoom
|
||||
}
|
||||
|
||||
var canStartReceive: Bool {
|
||||
!trimmedCode.isEmpty && !isBusy && !hasRoom
|
||||
}
|
||||
|
||||
var isWaitingForPeer: Bool {
|
||||
guard let status, status.running else { return false }
|
||||
return status.peers == 0
|
||||
}
|
||||
|
||||
var modeTitle: String {
|
||||
switch mode {
|
||||
case .send: return pickedFiles.isEmpty ? "Share files" : "Sending"
|
||||
case .receive: return "Receiving"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Picking files
|
||||
|
||||
func handleImport(_ result: Result<[URL], Error>) {
|
||||
switch result {
|
||||
case .success(let urls):
|
||||
importFiles(urls)
|
||||
case .failure(let error):
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
private func importFiles(_ urls: [URL]) {
|
||||
guard !urls.isEmpty else { return }
|
||||
isBusy = true
|
||||
errorMessage = nil
|
||||
let seedsDir = seedsDir
|
||||
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
var imported: [PickedFile] = []
|
||||
var failure: String?
|
||||
|
||||
for url in urls {
|
||||
let scoped = url.startAccessingSecurityScopedResource()
|
||||
defer {
|
||||
if scoped { url.stopAccessingSecurityScopedResource() }
|
||||
}
|
||||
|
||||
do {
|
||||
let values = try url.resourceValues(forKeys: [.isDirectoryKey, .fileSizeKey])
|
||||
if values.isDirectory == true {
|
||||
failure = "Folders aren't supported yet — pick individual files."
|
||||
continue
|
||||
}
|
||||
|
||||
// Copy into the app container before handing the path to Go.
|
||||
// The picker's security-scoped URL is only valid while this
|
||||
// call is on the stack, not for the lifetime of the room.
|
||||
let destinationDir = seedsDir.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: destinationDir, withIntermediateDirectories: true)
|
||||
let destination = destinationDir.appendingPathComponent(url.lastPathComponent)
|
||||
try FileManager.default.copyItem(at: url, to: destination)
|
||||
|
||||
let size = Int64((try? destination.resourceValues(forKeys: [.fileSizeKey]).fileSize) ?? 0)
|
||||
imported.append(PickedFile(url: destination, name: destination.lastPathComponent, size: size))
|
||||
} catch {
|
||||
failure = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self.pickedFiles.append(contentsOf: imported)
|
||||
self.isBusy = false
|
||||
if let failure { self.errorMessage = failure }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func removePickedFile(_ file: PickedFile) {
|
||||
pickedFiles.removeAll { $0.id == file.id }
|
||||
try? FileManager.default.removeItem(at: file.url.deletingLastPathComponent())
|
||||
}
|
||||
|
||||
func clearPickedFiles() {
|
||||
for file in pickedFiles {
|
||||
try? FileManager.default.removeItem(at: file.url.deletingLastPathComponent())
|
||||
}
|
||||
pickedFiles = []
|
||||
}
|
||||
|
||||
// MARK: - Starting a room
|
||||
|
||||
func startSend() {
|
||||
guard !pickedFiles.isEmpty else {
|
||||
errorMessage = "Choose at least one file first."
|
||||
return
|
||||
}
|
||||
guard let filesJSON = encodePaths(pickedFiles.map(\.url.path)) else {
|
||||
errorMessage = "Could not prepare the file list."
|
||||
return
|
||||
}
|
||||
let roomCode = trimmedCode.isEmpty ? ShareGenerateCode() : trimmedCode
|
||||
code = roomCode
|
||||
start(relay: relay, code: roomCode, filesJSON: filesJSON,
|
||||
outDir: receivedDir.path, oneTime: false)
|
||||
}
|
||||
|
||||
func startReceive() {
|
||||
guard !trimmedCode.isEmpty else {
|
||||
errorMessage = "Enter the room code shown on the other device."
|
||||
return
|
||||
}
|
||||
start(relay: relay, code: trimmedCode, filesJSON: "[]",
|
||||
outDir: receivedDir.path, oneTime: true)
|
||||
}
|
||||
|
||||
private func start(relay: String, code: String, filesJSON: String,
|
||||
outDir: String, oneTime: Bool) {
|
||||
errorMessage = nil
|
||||
isBusy = true
|
||||
stopPolling()
|
||||
let session = session
|
||||
|
||||
// Start dials the relay, which can take up to the dial timeout. Keep
|
||||
// that off the main thread so the button can show its spinner.
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
do {
|
||||
try session.start(relay, code: code, filesJSON: filesJSON,
|
||||
outDir: outDir, oneTime: oneTime)
|
||||
DispatchQueue.main.async {
|
||||
self.isBusy = false
|
||||
self.refresh()
|
||||
self.startPolling()
|
||||
}
|
||||
} catch {
|
||||
DispatchQueue.main.async {
|
||||
self.isBusy = false
|
||||
self.errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func stopRoom() {
|
||||
session.stop()
|
||||
refresh()
|
||||
stopPolling()
|
||||
flash("Room stopped")
|
||||
}
|
||||
|
||||
func reset() {
|
||||
session.stop()
|
||||
stopPolling()
|
||||
clearPickedFiles()
|
||||
status = nil
|
||||
errorMessage = nil
|
||||
code = ShareGenerateCode()
|
||||
}
|
||||
|
||||
func newCode() {
|
||||
code = ShareGenerateCode()
|
||||
}
|
||||
|
||||
// MARK: - Relay reachability
|
||||
|
||||
/// Debounced automatic check used while the Relay card is visible. The
|
||||
/// short delay keeps every keystroke in the relay field from opening a
|
||||
/// socket.
|
||||
func scheduleRelayCheck() {
|
||||
relayCheckTask?.cancel()
|
||||
relayCheckGeneration += 1
|
||||
let generation = relayCheckGeneration
|
||||
relayCheck = nil
|
||||
|
||||
relayCheckTask = Task { [weak self] in
|
||||
try? await Task.sleep(nanoseconds: 350_000_000)
|
||||
guard !Task.isCancelled, let self else { return }
|
||||
await self.performRelayCheck(generation: generation)
|
||||
}
|
||||
}
|
||||
|
||||
/// Immediate check, used by the refresh button.
|
||||
func checkRelay() {
|
||||
relayCheckTask?.cancel()
|
||||
relayCheckGeneration += 1
|
||||
let generation = relayCheckGeneration
|
||||
relayCheckTask = Task { [weak self] in
|
||||
guard let self else { return }
|
||||
await self.performRelayCheck(generation: generation)
|
||||
}
|
||||
}
|
||||
|
||||
private func performRelayCheck(generation: Int) async {
|
||||
isCheckingRelay = true
|
||||
let relay = relay
|
||||
|
||||
// ShareCheckRelay performs a dial plus the protocol ping/pong. Keep
|
||||
// that off the main thread; the Go function has its own 5s timeouts.
|
||||
let json = await withCheckedContinuation { (continuation: CheckedContinuation<String, Never>) in
|
||||
DispatchQueue.global(qos: .utility).async {
|
||||
continuation.resume(returning: ShareCheckRelay(relay))
|
||||
}
|
||||
}
|
||||
|
||||
guard generation == relayCheckGeneration else { return }
|
||||
isCheckingRelay = false
|
||||
|
||||
guard let data = json.data(using: .utf8),
|
||||
let decoded = try? JSONDecoder().decode(RelayCheck.self, from: data) else {
|
||||
relayCheck = RelayCheck(
|
||||
ok: false,
|
||||
relay: relay.isEmpty ? ShareRelayDefault() : relay,
|
||||
latencyMs: 0,
|
||||
error: "Could not read the relay status."
|
||||
)
|
||||
return
|
||||
}
|
||||
relayCheck = decoded
|
||||
}
|
||||
|
||||
// MARK: - Status polling
|
||||
|
||||
private func startPolling() {
|
||||
stopPolling()
|
||||
pollTimer = Timer.scheduledTimer(withTimeInterval: 0.6, repeats: true) { [weak self] _ in
|
||||
Task { @MainActor in self?.refresh() }
|
||||
}
|
||||
}
|
||||
|
||||
private func stopPolling() {
|
||||
pollTimer?.invalidate()
|
||||
pollTimer = nil
|
||||
}
|
||||
|
||||
func refresh() {
|
||||
guard let data = session.statusJSON().data(using: .utf8) else { return }
|
||||
guard let decoded = try? JSONDecoder().decode(RoomStatus.self, from: data) else { return }
|
||||
|
||||
// An idle Go session has an empty code. Don't turn that into a room
|
||||
// card before the user has started anything.
|
||||
if decoded.code.isEmpty && !decoded.running {
|
||||
return
|
||||
}
|
||||
|
||||
status = decoded
|
||||
if !decoded.running {
|
||||
stopPolling()
|
||||
}
|
||||
}
|
||||
|
||||
func handleScenePhase(_ phase: ScenePhase) {
|
||||
switch phase {
|
||||
case .active:
|
||||
refresh()
|
||||
if status != nil { startPolling() }
|
||||
case .background, .inactive:
|
||||
break
|
||||
@unknown default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Received files
|
||||
|
||||
func receivedURL(for file: FileProgress) -> URL? {
|
||||
guard file.complete else { return nil }
|
||||
let url = receivedDir.appendingPathComponent(file.name)
|
||||
return FileManager.default.fileExists(atPath: url.path) ? url : nil
|
||||
}
|
||||
|
||||
// MARK: - Small helpers
|
||||
|
||||
func copyCode() {
|
||||
UIPasteboard.general.string = status?.code ?? trimmedCode
|
||||
flash("Code copied")
|
||||
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
||||
}
|
||||
|
||||
func pasteCode() {
|
||||
guard let value = UIPasteboard.general.string?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else {
|
||||
errorMessage = "The clipboard is empty."
|
||||
return
|
||||
}
|
||||
code = value
|
||||
}
|
||||
|
||||
func flash(_ message: String) {
|
||||
notice = message
|
||||
noticeTask?.cancel()
|
||||
noticeTask = Task { [weak self] in
|
||||
try? await Task.sleep(nanoseconds: 2_000_000_000)
|
||||
guard !Task.isCancelled else { return }
|
||||
self?.notice = nil
|
||||
}
|
||||
}
|
||||
|
||||
private func encodePaths(_ paths: [String]) -> String? {
|
||||
guard let data = try? JSONEncoder().encode(paths) else { return nil }
|
||||
return String(data: data, encoding: .utf8)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0.831",
|
||||
"green" : "0.918",
|
||||
"red" : "0.369"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 887 KiB |
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "AppIcon.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
import SwiftUI
|
||||
|
||||
extension Color {
|
||||
init(hex: UInt32, opacity: Double = 1) {
|
||||
self.init(
|
||||
.sRGB,
|
||||
red: Double((hex >> 16) & 0xFF) / 255,
|
||||
green: Double((hex >> 8) & 0xFF) / 255,
|
||||
blue: Double(hex & 0xFF) / 255,
|
||||
opacity: opacity
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
enum Theme {
|
||||
static let background = Color.black
|
||||
static let card = Color.white.opacity(0.055)
|
||||
static let cardStrong = Color.white.opacity(0.08)
|
||||
static let stroke = Color.white.opacity(0.09)
|
||||
static let mint = Color(hex: 0x5EEAD4)
|
||||
static let blue = Color(hex: 0x60A5FA)
|
||||
static let amber = Color(hex: 0xFBBF24)
|
||||
static let danger = Color(hex: 0xFB7185)
|
||||
static let secondaryText = Color.white.opacity(0.62)
|
||||
static let tertiaryText = Color.white.opacity(0.4)
|
||||
}
|
||||
|
||||
struct AppBackground: View {
|
||||
var body: some View {
|
||||
Theme.background.ignoresSafeArea()
|
||||
}
|
||||
}
|
||||
|
||||
struct LogoMark: View {
|
||||
var size: CGFloat = 44
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: size * 0.29, style: .continuous)
|
||||
.fill(
|
||||
LinearGradient(
|
||||
colors: [Theme.mint, Theme.blue],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
)
|
||||
.shadow(color: Theme.mint.opacity(0.35), radius: 16, y: 6)
|
||||
|
||||
Image(systemName: "arrow.up.arrow.down")
|
||||
.font(.system(size: size * 0.4, weight: .bold))
|
||||
.foregroundStyle(Color(hex: 0x06211C))
|
||||
}
|
||||
.frame(width: size, height: size)
|
||||
}
|
||||
}
|
||||
|
||||
struct GlassCard<Content: View>: View {
|
||||
private let padding: CGFloat
|
||||
private let content: Content
|
||||
|
||||
init(padding: CGFloat = 20, @ViewBuilder content: () -> Content) {
|
||||
self.padding = padding
|
||||
self.content = content()
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
// Wrap the builder's tuple in a real layout container. Applying the
|
||||
// card background directly to a TupleView lets SwiftUI flatten the
|
||||
// children into the parent VStack, which visually breaks one card into
|
||||
// several rounded boxes.
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
content
|
||||
}
|
||||
.padding(padding)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Theme.card, in: RoundedRectangle(cornerRadius: 24, style: .continuous))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 24, style: .continuous)
|
||||
.strokeBorder(Theme.stroke, lineWidth: 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct FieldLabel: View {
|
||||
let title: String
|
||||
var hint: String?
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(title.uppercased())
|
||||
.font(.system(size: 11, weight: .semibold, design: .rounded))
|
||||
.tracking(1.4)
|
||||
.foregroundStyle(Theme.tertiaryText)
|
||||
if let hint {
|
||||
Text(hint)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(Theme.secondaryText)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct PrimaryButtonStyle: ButtonStyle {
|
||||
var tint: Color = Theme.mint
|
||||
var enabled: Bool = true
|
||||
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
configuration.label
|
||||
.font(.system(.headline, design: .rounded).weight(.semibold))
|
||||
.foregroundStyle(Color(hex: 0x06211C))
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 15)
|
||||
.background(
|
||||
LinearGradient(
|
||||
colors: enabled ? [tint, tint.opacity(0.78)] : [Color.white.opacity(0.16), Color.white.opacity(0.12)],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
),
|
||||
in: RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||
)
|
||||
.shadow(color: enabled ? tint.opacity(0.24) : .clear, radius: 14, y: 6)
|
||||
.scaleEffect(configuration.isPressed && enabled ? 0.985 : 1)
|
||||
.opacity(enabled ? 1 : 0.55)
|
||||
.animation(.easeOut(duration: 0.14), value: configuration.isPressed)
|
||||
}
|
||||
}
|
||||
|
||||
struct QuietButtonStyle: ButtonStyle {
|
||||
var tint: Color = Theme.secondaryText
|
||||
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
configuration.label
|
||||
.font(.system(.subheadline, design: .rounded).weight(.medium))
|
||||
.foregroundStyle(tint)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.background(Color.white.opacity(0.06), in: Capsule())
|
||||
.opacity(configuration.isPressed ? 0.65 : 1)
|
||||
}
|
||||
}
|
||||
|
||||
struct StatusPill: View {
|
||||
let running: Bool
|
||||
let peers: Int
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 7) {
|
||||
Circle()
|
||||
.fill(running ? Theme.mint : Theme.tertiaryText)
|
||||
.frame(width: 7, height: 7)
|
||||
.shadow(color: running ? Theme.mint.opacity(0.8) : .clear, radius: 5)
|
||||
|
||||
Text(label)
|
||||
.font(.system(size: 12, weight: .semibold, design: .rounded))
|
||||
.foregroundStyle(running ? Theme.mint : Theme.secondaryText)
|
||||
.lineLimit(1)
|
||||
}
|
||||
.padding(.horizontal, 11)
|
||||
.padding(.vertical, 7)
|
||||
.background(
|
||||
(running ? Theme.mint : Color.white).opacity(0.08),
|
||||
in: Capsule()
|
||||
)
|
||||
}
|
||||
|
||||
private var label: String {
|
||||
guard running else { return "Stopped" }
|
||||
if peers == 0 { return "Waiting for peer" }
|
||||
return "Live · \(peers) peer\(peers == 1 ? "" : "s")"
|
||||
}
|
||||
}
|
||||
|
||||
struct FileRow: View {
|
||||
let file: FileProgress
|
||||
let shareURL: URL?
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack(spacing: 11) {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
.fill(Theme.blue.opacity(0.12))
|
||||
.frame(width: 36, height: 36)
|
||||
Image(systemName: file.complete ? "checkmark" : "doc.fill")
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(file.complete ? Theme.mint : Theme.blue)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(file.name)
|
||||
.font(.system(.subheadline, design: .rounded).weight(.medium))
|
||||
.foregroundStyle(.white.opacity(0.92))
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
|
||||
Text(subtitle)
|
||||
.font(.caption)
|
||||
.foregroundStyle(Theme.secondaryText)
|
||||
}
|
||||
|
||||
Spacer(minLength: 8)
|
||||
|
||||
if let shareURL {
|
||||
ShareLink(item: shareURL) {
|
||||
Image(systemName: "square.and.arrow.up")
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(Theme.mint)
|
||||
.frame(width: 32, height: 32)
|
||||
.background(Theme.mint.opacity(0.1), in: Circle())
|
||||
}
|
||||
.accessibilityLabel("Share \(file.name)")
|
||||
}
|
||||
}
|
||||
|
||||
if !file.complete {
|
||||
ProgressView(value: file.complete ? 1 : file.fraction)
|
||||
.progressViewStyle(.linear)
|
||||
.tint(Theme.blue)
|
||||
.scaleEffect(x: 1, y: 0.75, anchor: .center)
|
||||
}
|
||||
}
|
||||
.padding(13)
|
||||
.background(Color.white.opacity(0.035), in: RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||
}
|
||||
|
||||
private var subtitle: String {
|
||||
if file.complete { return "\(file.formattedSize) · Complete" }
|
||||
let percent = Int((file.fraction * 100).rounded())
|
||||
return "\(file.formattedSize) · \(percent)% · \(file.haveChunks)/\(file.totalChunks) chunks"
|
||||
}
|
||||
}
|
||||
|
||||
struct ToastView: View {
|
||||
let message: String
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundStyle(Theme.mint)
|
||||
Text(message)
|
||||
.font(.system(.subheadline, design: .rounded).weight(.medium))
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 11)
|
||||
.background(.ultraThinMaterial, in: Capsule())
|
||||
.overlay { Capsule().strokeBorder(Theme.stroke, lineWidth: 1) }
|
||||
.shadow(color: .black.opacity(0.3), radius: 18, y: 8)
|
||||
}
|
||||
}
|
||||
|
||||
struct PulsingDot: View {
|
||||
@State private var animate = false
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(Theme.mint.opacity(0.18))
|
||||
.frame(width: 28, height: 28)
|
||||
.scaleEffect(animate ? 1.25 : 0.8)
|
||||
.opacity(animate ? 0 : 1)
|
||||
Circle()
|
||||
.fill(Theme.mint)
|
||||
.frame(width: 8, height: 8)
|
||||
}
|
||||
.onAppear {
|
||||
withAnimation(.easeOut(duration: 1.4).repeatForever(autoreverses: false)) {
|
||||
animate = true
|
||||
}
|
||||
}
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Share</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>Share</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>0.1.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<key>LSSupportsOpeningDocumentsInPlace</key>
|
||||
<true/>
|
||||
<key>NSLocalNetworkUsageDescription</key>
|
||||
<string>Share connects directly to nearby devices on your local network. If a direct connection is not possible, it uses the relay instead.</string>
|
||||
<key>UIFileSharingEnabled</key>
|
||||
<true/>
|
||||
<key>UILaunchScreen</key>
|
||||
<dict/>
|
||||
<key>UIRequiresFullScreen</key>
|
||||
<true/>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
</array>
|
||||
<key>UIUserInterfaceStyle</key>
|
||||
<string>Dark</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,91 @@
|
||||
import Foundation
|
||||
|
||||
/// Which side of the room the setup screen is preparing. Once a room is
|
||||
/// running the distinction only changes labels: every peer can send and
|
||||
/// receive, just like the CLI.
|
||||
enum RoomMode: String, CaseIterable, Identifiable {
|
||||
case send
|
||||
case receive
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .send: return "Send"
|
||||
case .receive: return "Receive"
|
||||
}
|
||||
}
|
||||
|
||||
var icon: String {
|
||||
switch self {
|
||||
case .send: return "arrow.up.circle.fill"
|
||||
case .receive: return "arrow.down.circle.fill"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One file's progress as reported by the Go engine. The JSON field names
|
||||
/// match `swarm.FileStatus`; no conversion layer is needed.
|
||||
struct FileProgress: Codable, Identifiable, Hashable {
|
||||
let name: String
|
||||
let size: Int64
|
||||
let haveChunks: Int64
|
||||
let totalChunks: Int64
|
||||
let complete: Bool
|
||||
|
||||
var id: String { name }
|
||||
|
||||
var fraction: Double {
|
||||
guard totalChunks > 0 else { return 0 }
|
||||
return min(1, max(0, Double(haveChunks) / Double(totalChunks)))
|
||||
}
|
||||
|
||||
var formattedSize: String {
|
||||
ByteCountFormatter.string(fromByteCount: size, countStyle: .file)
|
||||
}
|
||||
}
|
||||
|
||||
/// The whole room snapshot decoded from `ShareSession.statusJSON()`. The
|
||||
/// Go bridge keeps the same shape across launches so the UI never has to
|
||||
/// know about gomobile objects beyond the session itself.
|
||||
struct RoomStatus: Codable {
|
||||
var code: String
|
||||
var relay: String
|
||||
var outDir: String
|
||||
var running: Bool
|
||||
var peers: Int
|
||||
var files: [FileProgress]
|
||||
var error: String?
|
||||
|
||||
var allComplete: Bool {
|
||||
!files.isEmpty && files.allSatisfy(\.complete)
|
||||
}
|
||||
|
||||
var receivedFiles: [FileProgress] {
|
||||
files.filter(\.complete)
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of the relay's stateless ping/pong probe. `ok` is true only when
|
||||
/// the configured address accepted a connection and answered with pong,
|
||||
/// not merely when the TCP port was open.
|
||||
struct RelayCheck: Codable, Equatable {
|
||||
let ok: Bool
|
||||
let relay: String
|
||||
let latencyMs: Int64
|
||||
let error: String?
|
||||
}
|
||||
|
||||
/// A file that has already been copied into the app's seed directory.
|
||||
/// Holding the copied URL (rather than the security-scoped picker URL)
|
||||
/// means the Go engine can keep reading it after the picker goes away.
|
||||
struct PickedFile: Identifiable, Hashable {
|
||||
let id = UUID()
|
||||
let url: URL
|
||||
let name: String
|
||||
let size: Int64
|
||||
|
||||
var formattedSize: String {
|
||||
ByteCountFormatter.string(fromByteCount: size, countStyle: .file)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
import SwiftUI
|
||||
|
||||
struct RoomView: View {
|
||||
@EnvironmentObject private var model: AppModel
|
||||
|
||||
var body: some View {
|
||||
if let status = model.status {
|
||||
VStack(spacing: 16) {
|
||||
overviewCard(status)
|
||||
codeCard(status)
|
||||
filesCard(status)
|
||||
actionButton(status)
|
||||
}
|
||||
.animation(.easeInOut(duration: 0.22), value: status.files)
|
||||
.animation(.easeInOut(duration: 0.22), value: status.running)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Overview
|
||||
|
||||
private func overviewCard(_ status: RoomStatus) -> some View {
|
||||
GlassCard {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
.fill(
|
||||
LinearGradient(
|
||||
colors: model.mode == .send
|
||||
? [Theme.mint.opacity(0.2), Theme.mint.opacity(0.06)]
|
||||
: [Theme.blue.opacity(0.2), Theme.blue.opacity(0.06)],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
)
|
||||
.frame(width: 46, height: 46)
|
||||
Image(systemName: model.mode == .send ? "arrow.up" : "arrow.down")
|
||||
.font(.system(size: 18, weight: .bold))
|
||||
.foregroundStyle(model.mode == .send ? Theme.mint : Theme.blue)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(roomTitle(status))
|
||||
.font(.system(.headline, design: .rounded).weight(.semibold))
|
||||
.foregroundStyle(.white)
|
||||
Text(roomSubtitle(status))
|
||||
.font(.footnote)
|
||||
.foregroundStyle(Theme.secondaryText)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
Spacer(minLength: 8)
|
||||
|
||||
StatusPill(running: status.running, peers: status.peers)
|
||||
}
|
||||
|
||||
if status.running {
|
||||
HStack(spacing: 9) {
|
||||
if model.isWaitingForPeer {
|
||||
PulsingDot()
|
||||
Text("Waiting for the other device to join")
|
||||
} else {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundStyle(Theme.mint)
|
||||
Text("Connected · \(status.peers) peer\(status.peers == 1 ? "" : "s")")
|
||||
}
|
||||
}
|
||||
.font(.system(.subheadline, design: .rounded).weight(.medium))
|
||||
.foregroundStyle(Theme.secondaryText)
|
||||
.padding(.top, 14)
|
||||
} else if let error = status.error, !error.isEmpty {
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.foregroundStyle(Theme.danger)
|
||||
Text(error)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(Theme.danger.opacity(0.95))
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
.padding(.top, 13)
|
||||
} else if model.mode == .receive && status.allComplete {
|
||||
Label(
|
||||
"All files received",
|
||||
systemImage: "checkmark.seal.fill"
|
||||
)
|
||||
.font(.system(.subheadline, design: .rounded).weight(.medium))
|
||||
.foregroundStyle(Theme.mint)
|
||||
.padding(.top, 13)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func roomTitle(_ status: RoomStatus) -> String {
|
||||
if !status.running {
|
||||
if let error = status.error, !error.isEmpty { return "Room stopped" }
|
||||
if model.mode == .receive && status.allComplete { return "Room complete" }
|
||||
return "Room stopped"
|
||||
}
|
||||
return model.mode == .send ? "Sharing files" : "Receiving files"
|
||||
}
|
||||
|
||||
private func roomSubtitle(_ status: RoomStatus) -> String {
|
||||
if !status.running {
|
||||
if let error = status.error, !error.isEmpty {
|
||||
return "The connection to the relay was lost."
|
||||
}
|
||||
if model.mode == .receive && status.allComplete {
|
||||
return "The files are safe on your devices."
|
||||
}
|
||||
return "The room is no longer connected."
|
||||
}
|
||||
if model.mode == .send {
|
||||
return "Keep this screen open while the other device downloads."
|
||||
}
|
||||
return "Files will appear here as they arrive."
|
||||
}
|
||||
|
||||
// MARK: - Code
|
||||
|
||||
private func codeCard(_ status: RoomStatus) -> some View {
|
||||
GlassCard {
|
||||
FieldLabel(title: "Room code", hint: "The other device needs this exact code.")
|
||||
|
||||
HStack(spacing: 8) {
|
||||
Text(status.code)
|
||||
.font(.system(size: 21, weight: .bold, design: .monospaced))
|
||||
.foregroundStyle(.white)
|
||||
.minimumScaleFactor(0.55)
|
||||
.lineLimit(1)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 13)
|
||||
.padding(.vertical, 12)
|
||||
.background(Color.white.opacity(0.045), in: RoundedRectangle(cornerRadius: 13, style: .continuous))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 13, style: .continuous)
|
||||
.strokeBorder(Theme.stroke, lineWidth: 1)
|
||||
}
|
||||
.onTapGesture { model.copyCode() }
|
||||
.accessibilityAddTraits(.isButton)
|
||||
.accessibilityHint("Copies the room code")
|
||||
|
||||
Button {
|
||||
model.copyCode()
|
||||
} label: {
|
||||
Image(systemName: "doc.on.doc")
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(Theme.mint)
|
||||
.frame(width: 42, height: 42)
|
||||
.background(Theme.mint.opacity(0.1), in: RoundedRectangle(cornerRadius: 13, style: .continuous))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("Copy room code")
|
||||
|
||||
ShareLink(item: status.code) {
|
||||
Image(systemName: "square.and.arrow.up")
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(Theme.blue)
|
||||
.frame(width: 42, height: 42)
|
||||
.background(Theme.blue.opacity(0.1), in: RoundedRectangle(cornerRadius: 13, style: .continuous))
|
||||
}
|
||||
.accessibilityLabel("Share room code")
|
||||
}
|
||||
.padding(.top, 12)
|
||||
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "network")
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
Text(status.relay.isEmpty ? "tzgyn relay" : status.relay)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
}
|
||||
.font(.system(size: 10.5, design: .monospaced))
|
||||
.foregroundStyle(Theme.tertiaryText)
|
||||
.padding(.top, 10)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Files
|
||||
|
||||
private func filesCard(_ status: RoomStatus) -> some View {
|
||||
GlassCard {
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
Text("Files")
|
||||
.font(.system(.headline, design: .rounded).weight(.semibold))
|
||||
.foregroundStyle(.white)
|
||||
Spacer()
|
||||
if !status.files.isEmpty {
|
||||
Text("\(status.files.count)")
|
||||
.font(.system(size: 11, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(Theme.secondaryText)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 3)
|
||||
.background(Color.white.opacity(0.06), in: Capsule())
|
||||
}
|
||||
}
|
||||
|
||||
if status.files.isEmpty {
|
||||
VStack(spacing: 12) {
|
||||
if status.running {
|
||||
PulsingDot()
|
||||
Text("Waiting for files…")
|
||||
.font(.system(.subheadline, design: .rounded).weight(.medium))
|
||||
.foregroundStyle(Theme.secondaryText)
|
||||
} else {
|
||||
Image(systemName: "tray")
|
||||
.font(.system(size: 22, weight: .medium))
|
||||
.foregroundStyle(Theme.tertiaryText)
|
||||
Text("No files in this room")
|
||||
.font(.system(.subheadline, design: .rounded).weight(.medium))
|
||||
.foregroundStyle(Theme.secondaryText)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 26)
|
||||
} else {
|
||||
VStack(spacing: 8) {
|
||||
ForEach(sortedFiles(status.files)) { file in
|
||||
FileRow(
|
||||
file: file,
|
||||
shareURL: model.mode == .receive ? model.receivedURL(for: file) : nil
|
||||
)
|
||||
}
|
||||
}
|
||||
.padding(.top, 13)
|
||||
|
||||
if model.mode == .receive && status.allComplete {
|
||||
Text("Tap the share button to save a file to Photos, Files, or another app.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(Theme.tertiaryText)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.padding(.top, 3)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func sortedFiles(_ files: [FileProgress]) -> [FileProgress] {
|
||||
files.sorted {
|
||||
$0.name.localizedStandardCompare($1.name) == .orderedAscending
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
private func actionButton(_ status: RoomStatus) -> some View {
|
||||
Group {
|
||||
if status.running {
|
||||
Button {
|
||||
model.stopRoom()
|
||||
} label: {
|
||||
Label("Stop room", systemImage: "stop.fill")
|
||||
.font(.system(.headline, design: .rounded).weight(.semibold))
|
||||
.foregroundStyle(Theme.danger)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 14)
|
||||
.background(
|
||||
Theme.danger.opacity(0.09),
|
||||
in: RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||
)
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||
.strokeBorder(Theme.danger.opacity(0.18), lineWidth: 1)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
} else {
|
||||
Button {
|
||||
model.reset()
|
||||
} label: {
|
||||
Label("Start another room", systemImage: "arrow.counterclockwise")
|
||||
}
|
||||
.buttonStyle(PrimaryButtonStyle(tint: Theme.mint))
|
||||
}
|
||||
}
|
||||
.padding(.top, 2)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import SwiftUI
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
struct RootView: View {
|
||||
@EnvironmentObject private var model: AppModel
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
@State private var showImporter = false
|
||||
@Namespace private var modeNamespace
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
AppBackground()
|
||||
|
||||
ScrollView {
|
||||
VStack(spacing: 22) {
|
||||
header
|
||||
modePicker
|
||||
content
|
||||
footer
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.top, 14)
|
||||
.padding(.bottom, 36)
|
||||
.frame(maxWidth: 620)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.scrollDismissesKeyboard(.interactively)
|
||||
|
||||
if let notice = model.notice {
|
||||
VStack {
|
||||
Spacer()
|
||||
ToastView(message: notice)
|
||||
.padding(.bottom, 24)
|
||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||
}
|
||||
.animation(.spring(response: 0.35, dampingFraction: 0.82), value: model.notice)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
}
|
||||
.fileImporter(
|
||||
isPresented: $showImporter,
|
||||
allowedContentTypes: [.data],
|
||||
allowsMultipleSelection: true
|
||||
) { result in
|
||||
model.handleImport(result)
|
||||
}
|
||||
.alert(
|
||||
"Something went wrong",
|
||||
isPresented: Binding(
|
||||
get: { model.errorMessage != nil },
|
||||
set: { if !$0 { model.errorMessage = nil } }
|
||||
)
|
||||
) {
|
||||
Button("OK", role: .cancel) { model.errorMessage = nil }
|
||||
} message: {
|
||||
Text(model.errorMessage ?? "")
|
||||
}
|
||||
.onChange(of: scenePhase) { phase in
|
||||
model.handleScenePhase(phase)
|
||||
}
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
HStack(alignment: .center, spacing: 13) {
|
||||
LogoMark()
|
||||
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text("share")
|
||||
.font(.system(size: 27, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(.white)
|
||||
Text("direct device-to-device transfer")
|
||||
.font(.system(size: 12, weight: .medium, design: .rounded))
|
||||
.foregroundStyle(Theme.secondaryText)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
|
||||
private var modePicker: some View {
|
||||
HStack(spacing: 5) {
|
||||
ForEach(RoomMode.allCases) { mode in
|
||||
Button {
|
||||
withAnimation(.spring(response: 0.34, dampingFraction: 0.82)) {
|
||||
model.mode = mode
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: 7) {
|
||||
Image(systemName: mode.icon)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
Text(mode.title)
|
||||
.font(.system(.subheadline, design: .rounded).weight(.semibold))
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.85)
|
||||
}
|
||||
.foregroundStyle(
|
||||
model.mode == mode ? Color(hex: 0x06211C) : Theme.secondaryText
|
||||
)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 10)
|
||||
.background {
|
||||
if model.mode == mode {
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
.fill(
|
||||
LinearGradient(
|
||||
colors: mode == .send
|
||||
? [Theme.mint, Theme.mint.opacity(0.8)]
|
||||
: [Theme.blue, Theme.blue.opacity(0.8)],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
)
|
||||
.matchedGeometryEffect(id: "mode", in: modeNamespace)
|
||||
}
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.frame(maxWidth: .infinity)
|
||||
.accessibilityAddTraits(model.mode == mode ? .isSelected : [])
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(4)
|
||||
.background(Color.white.opacity(0.055), in: RoundedRectangle(cornerRadius: 18, style: .continuous))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 18, style: .continuous)
|
||||
.strokeBorder(Theme.stroke, lineWidth: 1)
|
||||
}
|
||||
.disabled(model.hasRoom || model.isBusy)
|
||||
.opacity(model.hasRoom || model.isBusy ? 0.55 : 1)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var content: some View {
|
||||
if model.hasRoom {
|
||||
RoomView()
|
||||
.transition(.asymmetric(
|
||||
insertion: .move(edge: .trailing).combined(with: .opacity),
|
||||
removal: .move(edge: .leading).combined(with: .opacity)
|
||||
))
|
||||
} else {
|
||||
SetupView(showImporter: $showImporter)
|
||||
.transition(.asymmetric(
|
||||
insertion: .move(edge: .leading).combined(with: .opacity),
|
||||
removal: .move(edge: .trailing).combined(with: .opacity)
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
private var footer: some View {
|
||||
VStack(spacing: 6) {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "lock.shield")
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
Text("End-to-end encrypted with a room-code key")
|
||||
.font(.system(size: 11, weight: .medium, design: .rounded))
|
||||
}
|
||||
.foregroundStyle(Theme.tertiaryText)
|
||||
|
||||
Text("The relay only helps peers find each other. File data goes directly between devices, or through an encrypted relay pipe when a direct path is blocked.")
|
||||
.font(.system(size: 10.5, design: .rounded))
|
||||
.foregroundStyle(Theme.tertiaryText.opacity(0.8))
|
||||
.multilineTextAlignment(.center)
|
||||
.lineSpacing(2)
|
||||
}
|
||||
.padding(.top, 2)
|
||||
.padding(.horizontal, 8)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import Foundation
|
||||
import Share
|
||||
import SwiftUI
|
||||
|
||||
/// A headless-ish smoke test driven by launch arguments:
|
||||
///
|
||||
/// xcrun simctl launch <udid> dev.tzgyn.share \
|
||||
/// -shareSelfTest -shareSelfTestRelay 127.0.0.1:9009 \
|
||||
/// -shareSelfTestCode 1234-selftest-share-apple
|
||||
///
|
||||
/// The test starts a receive room, waits for one complete file, and writes
|
||||
/// `Documents/share-selftest.txt`. Console output is unreliable from a
|
||||
/// simulator and impossible to read from a device, so the report on disk is
|
||||
/// the source of truth.
|
||||
@MainActor
|
||||
enum SelfTest {
|
||||
private static var started = false
|
||||
|
||||
static func runIfRequested(model: AppModel) {
|
||||
let args = ProcessInfo.processInfo.arguments
|
||||
guard !started else { return }
|
||||
let relay = value(after: "-shareSelfTestRelay", in: args) ?? ShareRelayDefault()
|
||||
let code = value(after: "-shareSelfTestCode", in: args)
|
||||
?? (args.contains("-shareSelfTestSend")
|
||||
? "1234-selftest-send-apple"
|
||||
: "1234-selftest-share-apple")
|
||||
|
||||
if args.contains("-shareSelfTestSend") {
|
||||
started = true
|
||||
runSend(model: model, relay: relay, code: code)
|
||||
} else if args.contains("-shareSelfTest") {
|
||||
started = true
|
||||
runReceive(model: model, relay: relay, code: code)
|
||||
}
|
||||
}
|
||||
|
||||
private static func runReceive(model: AppModel, relay: String, code: String) {
|
||||
model.mode = .receive
|
||||
model.relay = relay
|
||||
model.code = code
|
||||
model.startReceive()
|
||||
|
||||
Task { @MainActor in
|
||||
let deadline = Date().addingTimeInterval(120)
|
||||
var lastStatus: RoomStatus?
|
||||
|
||||
while Date() < deadline {
|
||||
try? await Task.sleep(nanoseconds: 500_000_000)
|
||||
if let status = model.status {
|
||||
lastStatus = status
|
||||
if status.allComplete {
|
||||
writeReport(result: "ok", model: model, status: status, error: nil)
|
||||
return
|
||||
}
|
||||
if !status.running, let error = status.error, !error.isEmpty {
|
||||
writeReport(result: "room-error", model: model, status: status, error: error)
|
||||
return
|
||||
}
|
||||
}
|
||||
if model.status == nil, let error = model.errorMessage, !error.isEmpty {
|
||||
writeReport(result: "start-error", model: model, status: nil, error: error)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
writeReport(
|
||||
result: "timeout",
|
||||
model: model,
|
||||
status: lastStatus,
|
||||
error: "no file completed before the 120s deadline"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private static func runSend(model: AppModel, relay: String, code: String) {
|
||||
let documents = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
|
||||
let seedDir = documents.appendingPathComponent("Seeds/selftest-send", isDirectory: true)
|
||||
let seedURL = seedDir.appendingPathComponent("share-send-selftest.bin")
|
||||
do {
|
||||
try FileManager.default.createDirectory(at: seedDir, withIntermediateDirectories: true)
|
||||
let data = Data(repeating: 0x5A, count: 2 * 1024 * 1024)
|
||||
try data.write(to: seedURL)
|
||||
} catch {
|
||||
writeReport(result: "seed-error", model: model, status: nil, error: error.localizedDescription)
|
||||
return
|
||||
}
|
||||
|
||||
model.mode = .send
|
||||
model.relay = relay
|
||||
model.code = code
|
||||
model.pickedFiles = [
|
||||
PickedFile(url: seedURL, name: seedURL.lastPathComponent, size: 2 * 1024 * 1024)
|
||||
]
|
||||
model.startSend()
|
||||
|
||||
Task { @MainActor in
|
||||
let deadline = Date().addingTimeInterval(120)
|
||||
var lastStatus: RoomStatus?
|
||||
var sawPeer = false
|
||||
|
||||
while Date() < deadline {
|
||||
try? await Task.sleep(nanoseconds: 500_000_000)
|
||||
if let status = model.status {
|
||||
lastStatus = status
|
||||
if status.peers > 0 { sawPeer = true }
|
||||
if sawPeer, status.allComplete {
|
||||
// Give the receiver a moment to finish the last chunks and
|
||||
// disconnect before recording the result.
|
||||
try? await Task.sleep(nanoseconds: 1_000_000_000)
|
||||
writeReport(result: "ok-seeded", model: model, status: status, error: nil)
|
||||
return
|
||||
}
|
||||
if !status.running, let error = status.error, !error.isEmpty {
|
||||
writeReport(result: "room-error", model: model, status: status, error: error)
|
||||
return
|
||||
}
|
||||
}
|
||||
if model.status == nil, let error = model.errorMessage, !error.isEmpty {
|
||||
writeReport(result: "start-error", model: model, status: nil, error: error)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
writeReport(
|
||||
result: "timeout",
|
||||
model: model,
|
||||
status: lastStatus,
|
||||
error: "no peer completed the seed before the 120s deadline"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private static func value(after flag: String, in args: [String]) -> String? {
|
||||
guard let index = args.firstIndex(of: flag), index + 1 < args.count else { return nil }
|
||||
return args[index + 1]
|
||||
}
|
||||
|
||||
private static func writeReport(
|
||||
result: String,
|
||||
model: AppModel,
|
||||
status: RoomStatus?,
|
||||
error: String?
|
||||
) {
|
||||
let documents = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
|
||||
let url = documents.appendingPathComponent("share-selftest.txt")
|
||||
|
||||
var lines = [
|
||||
"result=\(result)",
|
||||
"code=\(status?.code ?? model.code)",
|
||||
"relay=\(status?.relay ?? model.relay)",
|
||||
"running=\(status?.running ?? false)",
|
||||
"peers=\(status?.peers ?? 0)",
|
||||
"files=\(status?.files.count ?? 0)",
|
||||
]
|
||||
if let error, !error.isEmpty {
|
||||
lines.append("error=\(error.replacingOccurrences(of: "\n", with: " "))")
|
||||
}
|
||||
for file in status?.files ?? [] {
|
||||
let localURL: URL?
|
||||
if model.mode == .send {
|
||||
localURL = model.pickedFiles.first(where: { $0.name == file.name })?.url
|
||||
} else {
|
||||
localURL = model.receivedURL(for: file)
|
||||
}
|
||||
let exists = localURL.map { FileManager.default.fileExists(atPath: $0.path) } ?? false
|
||||
let size = localURL.flatMap {
|
||||
try? $0.resourceValues(forKeys: [.fileSizeKey]).fileSize
|
||||
} ?? 0
|
||||
lines.append(
|
||||
"file=\(file.name) size=\(file.size) chunks=\(file.haveChunks)/\(file.totalChunks) complete=\(file.complete) exists=\(exists) diskSize=\(size)"
|
||||
)
|
||||
}
|
||||
|
||||
try? lines.joined(separator: "\n").appending("\n").write(
|
||||
to: url,
|
||||
atomically: true,
|
||||
encoding: .utf8
|
||||
)
|
||||
print("Share self-test \(result): \(url.path)")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,418 @@
|
||||
import SwiftUI
|
||||
import Share
|
||||
|
||||
struct SetupView: View {
|
||||
@EnvironmentObject private var model: AppModel
|
||||
@Binding var showImporter: Bool
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 16) {
|
||||
if model.mode == .send {
|
||||
filesCard
|
||||
}
|
||||
codeCard
|
||||
relayCard
|
||||
startButton
|
||||
}
|
||||
.animation(.easeInOut(duration: 0.22), value: model.mode)
|
||||
}
|
||||
|
||||
// MARK: - Files
|
||||
|
||||
private var filesCard: some View {
|
||||
GlassCard {
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text("Files")
|
||||
.font(.system(.headline, design: .rounded).weight(.semibold))
|
||||
.foregroundStyle(.white)
|
||||
Text("Everything stays on your devices.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(Theme.secondaryText)
|
||||
}
|
||||
Spacer()
|
||||
if !model.pickedFiles.isEmpty {
|
||||
Button("Clear") {
|
||||
withAnimation(.easeInOut(duration: 0.2)) { model.clearPickedFiles() }
|
||||
}
|
||||
.buttonStyle(QuietButtonStyle(tint: Theme.secondaryText))
|
||||
}
|
||||
}
|
||||
|
||||
if model.pickedFiles.isEmpty {
|
||||
chooseButton
|
||||
.padding(.top, 14)
|
||||
} else {
|
||||
VStack(spacing: 8) {
|
||||
ForEach(model.pickedFiles) { file in
|
||||
pickedFileRow(file)
|
||||
}
|
||||
}
|
||||
.padding(.top, 14)
|
||||
|
||||
Button {
|
||||
showImporter = true
|
||||
} label: {
|
||||
Label("Add more files", systemImage: "plus")
|
||||
.font(.system(.subheadline, design: .rounded).weight(.semibold))
|
||||
.foregroundStyle(Theme.mint)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 11)
|
||||
.background(Theme.mint.opacity(0.08), in: RoundedRectangle(cornerRadius: 14, style: .continuous))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.top, 10)
|
||||
.disabled(model.isBusy)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var chooseButton: some View {
|
||||
Button {
|
||||
showImporter = true
|
||||
} label: {
|
||||
VStack(spacing: 10) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(Theme.mint.opacity(0.11))
|
||||
.frame(width: 54, height: 54)
|
||||
if model.isBusy {
|
||||
ProgressView()
|
||||
.tint(Theme.mint)
|
||||
} else {
|
||||
Image(systemName: "folder.badge.plus")
|
||||
.font(.system(size: 21, weight: .medium))
|
||||
.foregroundStyle(Theme.mint)
|
||||
}
|
||||
}
|
||||
|
||||
VStack(spacing: 3) {
|
||||
Text(model.isBusy ? "Preparing files…" : "Choose files")
|
||||
.font(.system(.headline, design: .rounded).weight(.semibold))
|
||||
.foregroundStyle(.white)
|
||||
Text("Any file type · multiple selection")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(Theme.secondaryText)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 24)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 18, style: .continuous)
|
||||
.fill(Color.white.opacity(0.025))
|
||||
)
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 18, style: .continuous)
|
||||
.strokeBorder(
|
||||
Theme.mint.opacity(0.35),
|
||||
style: StrokeStyle(lineWidth: 1.2, dash: [6, 5])
|
||||
)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(model.isBusy)
|
||||
}
|
||||
|
||||
private func pickedFileRow(_ file: PickedFile) -> some View {
|
||||
HStack(spacing: 11) {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
.fill(Theme.blue.opacity(0.12))
|
||||
.frame(width: 34, height: 34)
|
||||
Image(systemName: "doc.fill")
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundStyle(Theme.blue)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(file.name)
|
||||
.font(.system(.subheadline, design: .rounded).weight(.medium))
|
||||
.foregroundStyle(.white.opacity(0.92))
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
Text(file.formattedSize)
|
||||
.font(.caption)
|
||||
.foregroundStyle(Theme.secondaryText)
|
||||
}
|
||||
|
||||
Spacer(minLength: 8)
|
||||
|
||||
Button {
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
model.removePickedFile(file)
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "xmark")
|
||||
.font(.system(size: 11, weight: .bold))
|
||||
.foregroundStyle(Theme.secondaryText)
|
||||
.frame(width: 28, height: 28)
|
||||
.background(Color.white.opacity(0.06), in: Circle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("Remove \(file.name)")
|
||||
}
|
||||
.padding(10)
|
||||
.background(Color.white.opacity(0.035), in: RoundedRectangle(cornerRadius: 14, style: .continuous))
|
||||
}
|
||||
|
||||
// MARK: - Code
|
||||
|
||||
private var codeCard: some View {
|
||||
GlassCard {
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
FieldLabel(
|
||||
title: "Room code",
|
||||
hint: model.mode == .send
|
||||
? "Share this with the device that should receive the files."
|
||||
: "Enter the code shown on the sending device."
|
||||
)
|
||||
|
||||
Spacer(minLength: 12)
|
||||
|
||||
if model.mode == .send {
|
||||
Button {
|
||||
withAnimation(.easeInOut(duration: 0.2)) { model.newCode() }
|
||||
} label: {
|
||||
Label("New", systemImage: "arrow.clockwise")
|
||||
}
|
||||
.buttonStyle(QuietButtonStyle(tint: Theme.mint))
|
||||
.accessibilityLabel("Generate a new code")
|
||||
} else {
|
||||
Button {
|
||||
model.pasteCode()
|
||||
} label: {
|
||||
Label("Paste", systemImage: "doc.on.clipboard")
|
||||
}
|
||||
.buttonStyle(QuietButtonStyle(tint: Theme.blue))
|
||||
.accessibilityLabel("Paste a code")
|
||||
}
|
||||
}
|
||||
|
||||
TextField("1234-word-word-word-word", text: $model.code)
|
||||
.font(.system(size: 15, weight: .semibold, design: .monospaced))
|
||||
.foregroundStyle(.white)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
.keyboardType(.asciiCapable)
|
||||
.submitLabel(model.mode == .send ? .go : .join)
|
||||
.onSubmit {
|
||||
if model.mode == .send {
|
||||
model.startSend()
|
||||
} else {
|
||||
model.startReceive()
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 13)
|
||||
.padding(.vertical, 12)
|
||||
.background(Color.white.opacity(0.05), in: RoundedRectangle(cornerRadius: 13, style: .continuous))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 13, style: .continuous)
|
||||
.strokeBorder(Theme.stroke, lineWidth: 1)
|
||||
}
|
||||
.padding(.top, 12)
|
||||
|
||||
if model.mode == .send {
|
||||
Text("On the Mac, run `share \(model.trimmedCode.isEmpty ? "…" : model.trimmedCode)`")
|
||||
.font(.system(size: 11.5, design: .monospaced))
|
||||
.foregroundStyle(Theme.tertiaryText)
|
||||
.padding(.top, 9)
|
||||
.lineLimit(2)
|
||||
} else {
|
||||
Text("The code is also the encryption passphrase. It never leaves your devices.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(Theme.tertiaryText)
|
||||
.padding(.top, 9)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Relay
|
||||
|
||||
private var relayCard: some View {
|
||||
GlassCard(padding: 16) {
|
||||
Button {
|
||||
withAnimation(.easeInOut(duration: 0.22)) {
|
||||
model.showAdvanced.toggle()
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: 11) {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
.fill(Theme.blue.opacity(0.1))
|
||||
.frame(width: 34, height: 34)
|
||||
Image(systemName: "network")
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(Theme.blue)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Relay")
|
||||
.font(.system(.subheadline, design: .rounded).weight(.semibold))
|
||||
.foregroundStyle(.white)
|
||||
Text(model.relay.isEmpty ? ShareRelayDefault() : model.relay)
|
||||
.font(.system(size: 11.5, design: .monospaced))
|
||||
.foregroundStyle(Theme.secondaryText)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "chevron.down")
|
||||
.font(.system(size: 11, weight: .bold))
|
||||
.foregroundStyle(Theme.tertiaryText)
|
||||
.rotationEffect(.degrees(model.showAdvanced ? 180 : 0))
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
relayStatusRow
|
||||
|
||||
if model.showAdvanced {
|
||||
VStack(alignment: .leading, spacing: 9) {
|
||||
TextField("host:port", text: $model.relay)
|
||||
.font(.system(.subheadline, design: .monospaced))
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
.keyboardType(.URL)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 10)
|
||||
.background(Color.white.opacity(0.05), in: RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.strokeBorder(Theme.stroke, lineWidth: 1)
|
||||
}
|
||||
|
||||
HStack(alignment: .top, spacing: 7) {
|
||||
Image(systemName: "info.circle")
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.padding(.top, 1)
|
||||
Text("Use your Mac's LAN address, like 192.168.1.20:9009, when you run `share relay` there. Leave it empty for the tzgyn relay.")
|
||||
.font(.caption)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
.foregroundStyle(Theme.tertiaryText)
|
||||
|
||||
if !model.relay.isEmpty {
|
||||
Button("Use tzgyn relay") {
|
||||
model.relay = ShareRelayDefault()
|
||||
}
|
||||
.buttonStyle(QuietButtonStyle(tint: Theme.mint))
|
||||
}
|
||||
}
|
||||
.padding(.top, 13)
|
||||
.transition(.opacity.combined(with: .move(edge: .top)))
|
||||
}
|
||||
}
|
||||
.task { model.scheduleRelayCheck() }
|
||||
.onChange(of: model.relay) { _ in model.scheduleRelayCheck() }
|
||||
}
|
||||
|
||||
private var relayStatusRow: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack(spacing: 7) {
|
||||
if model.isCheckingRelay {
|
||||
ProgressView()
|
||||
.controlSize(.mini)
|
||||
.tint(Theme.blue)
|
||||
} else {
|
||||
Circle()
|
||||
.fill(relayStatusColor)
|
||||
.frame(width: 7, height: 7)
|
||||
}
|
||||
|
||||
Text(relayStatusText)
|
||||
.font(.system(size: 11.5, weight: .medium, design: .rounded))
|
||||
.foregroundStyle(relayStatusColor)
|
||||
.lineLimit(1)
|
||||
|
||||
Spacer(minLength: 8)
|
||||
|
||||
Button {
|
||||
model.checkRelay()
|
||||
} label: {
|
||||
Image(systemName: "arrow.clockwise")
|
||||
.font(.system(size: 11, weight: .bold))
|
||||
.foregroundStyle(Theme.secondaryText)
|
||||
.frame(width: 28, height: 28)
|
||||
.background(Color.white.opacity(0.05), in: Circle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(model.isCheckingRelay)
|
||||
.accessibilityLabel("Check backend connectivity")
|
||||
}
|
||||
|
||||
if let error = model.relayCheck?.error,
|
||||
!error.isEmpty,
|
||||
model.relayCheck?.ok == false {
|
||||
Text(error)
|
||||
.font(.system(size: 10, design: .rounded))
|
||||
.foregroundStyle(Theme.danger.opacity(0.85))
|
||||
.lineLimit(2)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
.padding(.top, 10)
|
||||
}
|
||||
|
||||
private var relayStatusColor: Color {
|
||||
if model.isCheckingRelay { return Theme.blue }
|
||||
guard let check = model.relayCheck else { return Theme.tertiaryText }
|
||||
return check.ok ? Theme.mint : Theme.danger
|
||||
}
|
||||
|
||||
private var relayStatusText: String {
|
||||
if model.isCheckingRelay { return "Checking backend…" }
|
||||
guard let check = model.relayCheck else { return "Backend status unknown" }
|
||||
if check.ok {
|
||||
return "Backend reachable · \(check.latencyMs) ms"
|
||||
}
|
||||
return "Backend unreachable"
|
||||
}
|
||||
|
||||
// MARK: - Start
|
||||
|
||||
private var startButton: some View {
|
||||
Button {
|
||||
if model.mode == .send {
|
||||
model.startSend()
|
||||
} else {
|
||||
model.startReceive()
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: 9) {
|
||||
if model.isBusy && !model.hasRoom {
|
||||
ProgressView()
|
||||
.tint(Color(hex: 0x06211C))
|
||||
} else {
|
||||
Image(systemName: model.mode == .send ? "paperplane.fill" : "arrow.down.circle.fill")
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
}
|
||||
Text(buttonTitle)
|
||||
}
|
||||
}
|
||||
.buttonStyle(
|
||||
PrimaryButtonStyle(
|
||||
tint: model.mode == .send ? Theme.mint : Theme.blue,
|
||||
enabled: canStart
|
||||
)
|
||||
)
|
||||
.disabled(!canStart)
|
||||
.padding(.top, 2)
|
||||
}
|
||||
|
||||
private var canStart: Bool {
|
||||
model.mode == .send ? model.canStartSend : model.canStartReceive
|
||||
}
|
||||
|
||||
private var buttonTitle: String {
|
||||
if model.isBusy && !model.hasRoom {
|
||||
return model.pickedFiles.isEmpty ? "Preparing…" : "Joining relay…"
|
||||
}
|
||||
if model.mode == .send {
|
||||
return model.pickedFiles.isEmpty ? "Choose files to start" : "Start sharing"
|
||||
}
|
||||
return model.trimmedCode.isEmpty ? "Enter a room code" : "Join room"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct ShareApp: App {
|
||||
@StateObject private var model: AppModel
|
||||
|
||||
init() {
|
||||
let model = AppModel()
|
||||
_model = StateObject(wrappedValue: model)
|
||||
Task { @MainActor in
|
||||
SelfTest.runIfRequested(model: model)
|
||||
}
|
||||
}
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
RootView()
|
||||
.environmentObject(model)
|
||||
.preferredColorScheme(.dark)
|
||||
.tint(Theme.mint)
|
||||
}
|
||||
}
|
||||
}
|
||||
Executable
+22
@@ -0,0 +1,22 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build the Go swarm engine as an iOS XCFramework. Run this before
|
||||
# `xcodegen generate` whenever the Go bridge or anything under src/ changes.
|
||||
set -euo pipefail
|
||||
|
||||
HERE="$(cd "$(dirname "$0")" && pwd)"
|
||||
TOOLS="$HERE/.build/tools"
|
||||
mkdir -p "$TOOLS"
|
||||
|
||||
cd "$HERE/GoBridge"
|
||||
|
||||
# Install the exact gomobile/gobind versions recorded in this module. The
|
||||
# tools live in the app folder so they are easy to delete and never leak
|
||||
# into the rest of the repo.
|
||||
GOBIN="$TOOLS" go install golang.org/x/mobile/cmd/gomobile golang.org/x/mobile/cmd/gobind
|
||||
export PATH="$TOOLS:$PATH"
|
||||
|
||||
gomobile init
|
||||
rm -rf "$HERE/Share.xcframework"
|
||||
gomobile bind -target=ios -o "$HERE/Share.xcframework" .
|
||||
|
||||
echo "Built $HERE/Share.xcframework"
|
||||
Executable
+41
@@ -0,0 +1,41 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build the native iOS app. Usage:
|
||||
# ./build.sh simulator (default)
|
||||
# ./build.sh device (signed with the TZGyn team)
|
||||
set -euo pipefail
|
||||
|
||||
HERE="$(cd "$(dirname "$0")" && pwd)"
|
||||
TARGET="${1:-simulator}"
|
||||
|
||||
"$HERE/build-go.sh"
|
||||
cd "$HERE"
|
||||
xcodegen generate
|
||||
|
||||
case "$TARGET" in
|
||||
simulator|sim)
|
||||
xcodebuild \
|
||||
-project Share.xcodeproj \
|
||||
-scheme Share \
|
||||
-configuration Debug \
|
||||
-sdk iphonesimulator \
|
||||
-destination 'generic/platform=iOS Simulator' \
|
||||
ARCHS=arm64 ONLY_ACTIVE_ARCH=NO \
|
||||
-derivedDataPath /tmp/share-dd \
|
||||
build
|
||||
;;
|
||||
device)
|
||||
xcodebuild \
|
||||
-project Share.xcodeproj \
|
||||
-scheme Share \
|
||||
-configuration Debug \
|
||||
-sdk iphoneos \
|
||||
-destination 'generic/platform=iOS' \
|
||||
-allowProvisioningUpdates \
|
||||
-derivedDataPath /tmp/share-dd-device \
|
||||
build
|
||||
;;
|
||||
*)
|
||||
echo "usage: $0 [simulator|device]" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
@@ -0,0 +1,64 @@
|
||||
name: Share
|
||||
options:
|
||||
bundleIdPrefix: dev.tzgyn
|
||||
deploymentTarget:
|
||||
iOS: "16.0"
|
||||
xcodeVersion: "16.0"
|
||||
|
||||
settings:
|
||||
base:
|
||||
SWIFT_VERSION: "5.0"
|
||||
ENABLE_USER_SCRIPT_SANDBOXING: NO
|
||||
IPHONEOS_DEPLOYMENT_TARGET: "16.0"
|
||||
|
||||
targets:
|
||||
Share:
|
||||
type: application
|
||||
platform: iOS
|
||||
sources:
|
||||
- path: Share
|
||||
dependencies:
|
||||
# gomobile builds this from apps/ios/GoBridge by running
|
||||
# apps/ios/build-go.sh. It is a static framework, so it is linked
|
||||
# into the app rather than embedded as a separate bundle.
|
||||
- framework: Share.xcframework
|
||||
embed: false
|
||||
settings:
|
||||
base:
|
||||
PRODUCT_BUNDLE_IDENTIFIER: dev.tzgyn.share
|
||||
PRODUCT_NAME: Share
|
||||
# The app target is also called Share; without a distinct Swift
|
||||
# module name, `import Share` would resolve to the app itself and
|
||||
# the gomobile framework would be invisible.
|
||||
PRODUCT_MODULE_NAME: ShareIOS
|
||||
INFOPLIST_FILE: Share/Info.plist
|
||||
CODE_SIGN_STYLE: Automatic
|
||||
DEVELOPMENT_TEAM: RX6767S6BW
|
||||
CODE_SIGNING_ALLOWED[sdk=iphonesimulator*]: NO
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
|
||||
OTHER_LDFLAGS:
|
||||
- "$(inherited)"
|
||||
- "-ObjC"
|
||||
info:
|
||||
path: Share/Info.plist
|
||||
properties:
|
||||
CFBundleDisplayName: Share
|
||||
CFBundleName: Share
|
||||
CFBundleShortVersionString: "0.1.0"
|
||||
CFBundleVersion: "1"
|
||||
UILaunchScreen: {}
|
||||
UISupportedInterfaceOrientations:
|
||||
- UIInterfaceOrientationPortrait
|
||||
UIRequiresFullScreen: true
|
||||
UIUserInterfaceStyle: Dark
|
||||
NSLocalNetworkUsageDescription: Share connects directly to nearby devices on your local network. If a direct connection is not possible, it uses the relay instead.
|
||||
UIFileSharingEnabled: true
|
||||
LSSupportsOpeningDocumentsInPlace: true
|
||||
|
||||
schemes:
|
||||
Share:
|
||||
build:
|
||||
targets:
|
||||
Share: all
|
||||
run:
|
||||
config: Debug
|
||||
Executable
+15
@@ -0,0 +1,15 @@
|
||||
#!/usr/bin/env bash
|
||||
# Install the signed device build on a paired iPhone and launch it.
|
||||
# Usage: ./run-device.sh [device-name-or-udid]
|
||||
set -euo pipefail
|
||||
|
||||
DEVICE="${1:-FeelsCringeMan}"
|
||||
APP="/tmp/share-dd-device/Build/Products/Debug-iphoneos/Share.app"
|
||||
|
||||
if [[ ! -d "$APP" ]]; then
|
||||
echo "No device build at $APP. Run ./build.sh device first." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
xcrun devicectl device install app --device "$DEVICE" --timeout 180 "$APP"
|
||||
xcrun devicectl device process launch --device "$DEVICE" dev.tzgyn.share
|
||||
@@ -15,10 +15,12 @@ import (
|
||||
// TCP_BUFFER_SIZE is the maximum packet size
|
||||
const TCP_BUFFER_SIZE = 1024 * 64
|
||||
|
||||
// DEFAULT_RELAY is the default relay used (can be set using --relay)
|
||||
// DEFAULT_RELAY is the default relay used (can be set using --relay).
|
||||
// The project runs its own relay at share.tzgyn.dev; both the CLI and the
|
||||
// iOS app default to it instead of the upstream croc relay.
|
||||
var (
|
||||
DEFAULT_RELAY = "croc.schollz.com"
|
||||
DEFAULT_RELAY6 = "croc6.schollz.com"
|
||||
DEFAULT_RELAY = "share.tzgyn.dev"
|
||||
DEFAULT_RELAY6 = "share.tzgyn.dev"
|
||||
DEFAULT_PORT = "9009"
|
||||
INTERNAL_DNS = false
|
||||
)
|
||||
|
||||
+14
-1
@@ -43,8 +43,12 @@ type PeerInfo struct {
|
||||
// (used here as an arbitrary pairing key, not an actual room) get piped
|
||||
// together — the relay just forwards raw bytes between them from that
|
||||
// point on, blind to whatever protocol the two peers speak over it.
|
||||
//
|
||||
// "ping" is a stateless health check: the relay answers "pong" and closes
|
||||
// without creating a room. The iOS app uses it to show whether the
|
||||
// configured backend is reachable and actually speaks the share protocol.
|
||||
type ControlMessage struct {
|
||||
Type string `json:"type"` // "join", "peers", or "pipe"
|
||||
Type string `json:"type"` // "join", "peers", "pipe", "ping", or "pong"
|
||||
Room string `json:"room,omitempty"`
|
||||
Peer PeerInfo `json:"peer,omitempty"`
|
||||
Peers []PeerInfo `json:"peers,omitempty"`
|
||||
@@ -229,6 +233,15 @@ func (s *server) handleConnection(connection net.Conn) {
|
||||
return
|
||||
}
|
||||
s.handlePipe(c, msg.Room)
|
||||
case "ping":
|
||||
// Stateless health check: no room, no retained state. Answering
|
||||
// "pong" also proves this is a share relay rather than some other
|
||||
// TCP service that happened to accept the connection.
|
||||
pong, err := json.Marshal(ControlMessage{Type: "pong"})
|
||||
if err == nil {
|
||||
_ = c.Send(pong)
|
||||
}
|
||||
connection.Close()
|
||||
default:
|
||||
log.Warnf("client %s sent an unknown control message type %q", connection.RemoteAddr().String(), msg.Type)
|
||||
connection.Close()
|
||||
|
||||
Reference in New Issue
Block a user