Add a native iOS client for relay-based file sharing

The SwiftUI app bundles the Go swarm engine through gomobile, so an iPhone is a real peer using the existing PAKE handshake, chunk verification, and relay fallback. XcodeGen builds the project and devicectl installs it on the paired phone.
This commit is contained in:
2026-10-26 22:24:47 +08:00
parent 29fa4a4c44
commit 9a37e93cfc
23 changed files with 2458 additions and 0 deletions
+14
View File
@@ -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/
+173
View File
@@ -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)")
+24
View File
@@ -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 => ../../..
+32
View File
@@ -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=
+236
View File
@@ -0,0 +1,236 @@
// Package share is the gomobile bridge between the native iOS app and
// the Go swarm engine in this repository.
//
// gomobile cannot expose every Go type to Swift, so this package keeps the
// boundary deliberately narrow: Swift owns the screens and file pickers,
// while this package owns the one long-lived swarm.Client for the room on
// screen. Swift calls Start once, polls StatusJSON for progress, and calls
// Stop when the user leaves the room.
package share
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sync"
"github.com/tzgyn/share/src/swarm"
"github.com/tzgyn/share/src/utils"
)
// DefaultRelay is the public relay the CLI uses when the user does not
// pass --relay. The iOS app lets the user override it (for example with a
// relay running on their Mac's LAN address).
const DefaultRelay = "croc.schollz.com:9009"
// status is the JSON shape the Swift app decodes from StatusJSON. It
// mirrors swarm.RoomStatus but adds the session fields that live only on
// this bridge: whether the room is still running, which relay it used,
// and the last fatal error, if any. It stays unexported because Swift
// reads the JSON rather than this Go type directly.
type status struct {
Code string `json:"code"`
Relay string `json:"relay"`
OutDir string `json:"outDir"`
Running bool `json:"running"`
Peers int `json:"peers"`
Files []swarm.FileStatus `json:"files"`
Error string `json:"error,omitempty"`
}
// Session is one room, owned by the iOS app. A Session is not safe to
// reuse for two rooms at once; Start refuses while another room is live.
// That is intentional: the phone UI has one Send/Receive sheet at a time,
// and one Client means one set of progress counters to display.
type Session struct {
mu sync.Mutex
client *swarm.Client
code string
relay string
outDir string
running bool
lastErr string
generation uint64
lastRoom status // retained after the room ends, so the final 100% is visible
}
// NewSession creates an idle session. The Swift side holds exactly one of
// these for the lifetime of the app.
func NewSession() *Session {
return &Session{}
}
// GenerateCode returns a new human-readable room code in the same
// "1234-word-word-word-word" shape the CLI prints. Both sides must type
// the same code to derive the same handshake secret.
func GenerateCode() string {
return utils.GetRandomName()
}
// RelayDefault returns the relay address used when the app's relay field
// is empty. It is a function rather than a bare constant because gomobile
// only generates callable symbols for exported functions and methods.
func RelayDefault() string {
return DefaultRelay
}
// Start joins one room. filesJSON is a JSON array of absolute paths inside
// the app sandbox; pass "[]" to be a pure receiver. outDir is created if
// needed and must also be absolute. oneTime stops the room as soon as every
// file currently known is complete, which is useful for a receiver that
// does not want to keep seeding in the background.
//
// Start returns as soon as the relay join succeeds; progress is read with
// StatusJSON. The room runs in a goroutine until Stop or oneTime
// completion.
func (s *Session) Start(relay, code, filesJSON, outDir string, oneTime bool) error {
s.mu.Lock()
if s.client != nil {
s.mu.Unlock()
return fmt.Errorf("a room is already active; stop it before starting another")
}
s.mu.Unlock()
var files []string
if filesJSON != "" {
if err := json.Unmarshal([]byte(filesJSON), &files); err != nil {
return fmt.Errorf("invalid file list: %w", err)
}
}
if relay == "" {
relay = DefaultRelay
}
if code == "" {
code = GenerateCode()
}
if outDir == "" {
return fmt.Errorf("output folder is required")
}
if !filepath.IsAbs(outDir) {
return fmt.Errorf("output folder must be an absolute path: %s", outDir)
}
for _, file := range files {
if !filepath.IsAbs(file) {
return fmt.Errorf("file path must be absolute: %s", file)
}
}
if err := os.MkdirAll(outDir, 0o755); err != nil {
return fmt.Errorf("could not create output folder: %w", err)
}
cl, err := swarm.Start(swarm.Options{
RelayAddress: relay,
Code: code,
Files: files,
OutDir: outDir,
OneTime: oneTime,
})
if err != nil {
return err
}
s.mu.Lock()
s.generation++
generation := s.generation
s.client = cl
s.code = code
s.relay = relay
s.outDir = outDir
s.running = true
s.lastErr = ""
s.lastRoom = status{
Code: code,
Relay: relay,
OutDir: outDir,
Running: true,
Files: make([]swarm.FileStatus, 0),
}
s.mu.Unlock()
// Wait is the only way to learn that the relay connection died. Do it
// off the main thread and fold the final Client.Status into lastRoom
// before releasing client, so a finished one-time transfer still shows
// its completed files after the room stops. The generation check keeps
// a stopped room's late Wait from clobbering a room the user started
// immediately afterwards.
go func() {
waitErr := cl.Wait()
final := cl.Status()
s.mu.Lock()
defer s.mu.Unlock()
if s.generation != generation {
return
}
s.running = false
s.client = nil
if waitErr != nil {
s.lastErr = waitErr.Error()
}
s.lastRoom = status{
Code: s.code,
Relay: s.relay,
OutDir: s.outDir,
Running: false,
Peers: final.Peers,
Files: final.Files,
Error: s.lastErr,
}
}()
return nil
}
// StatusJSON serializes the room's current state for Swift. JSON at the
// boundary keeps the gomobile surface to strings, booleans, and []string
// instead of making every nested progress type a generated Swift class.
func (s *Session) StatusJSON() string {
s.mu.Lock()
cl := s.client
room := s.lastRoom
running := s.running
lastErr := s.lastErr
code, relay, outDir := s.code, s.relay, s.outDir
s.mu.Unlock()
if cl != nil {
live := cl.Status()
room = status{
Code: code,
Relay: relay,
OutDir: outDir,
Running: running,
Peers: live.Peers,
Files: live.Files,
Error: lastErr,
}
} else {
room.Running = false
room.Error = lastErr
}
if room.Files == nil {
room.Files = make([]swarm.FileStatus, 0)
}
b, err := json.Marshal(room)
if err != nil {
return `{"error":"could not encode status"}`
}
return string(b)
}
// Stop leaves the current room. It is safe to call when no room is
// running; the UI uses that on disappear and when the app returns to the
// foreground to avoid a stale relay connection.
func (s *Session) Stop() {
s.mu.Lock()
cl := s.client
s.client = nil
s.running = false
s.mu.Unlock()
if cl != nil {
cl.Stop()
}
}
+108
View File
@@ -0,0 +1,108 @@
# 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 `croc.schollz.com:9009`. For local transfers, run
`share relay` on a Mac and enter its LAN address (`192.168.x.x:9009`) under
Relay in the app.
## 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.
+295
View File
@@ -0,0 +1,295 @@
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
private let session: ShareSession = ShareNewSession()!
private let receivedDir: URL
private let seedsDir: URL
private var pollTimer: Timer?
private var noticeTask: Task<Void, Never>?
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: - 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
}
}
+273
View File
@@ -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)
}
}
+40
View File
@@ -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>
+81
View File
@@ -0,0 +1,81 @@
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)
}
}
/// 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)
}
}
+276
View 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 ? "public 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)
}
}
+169
View File
@@ -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)
}
}
+180
View File
@@ -0,0 +1,180 @@
import Foundation
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) ?? "127.0.0.1:9009"
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)")
}
}
+352
View File
@@ -0,0 +1,352 @@
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)
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 public relay.")
.font(.caption)
.fixedSize(horizontal: false, vertical: true)
}
.foregroundStyle(Theme.tertiaryText)
if !model.relay.isEmpty {
Button("Use public relay") {
model.relay = ShareRelayDefault()
}
.buttonStyle(QuietButtonStyle(tint: Theme.mint))
}
}
.padding(.top, 13)
.transition(.opacity.combined(with: .move(edge: .top)))
}
}
}
// 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"
}
}
+23
View File
@@ -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)
}
}
}
+22
View File
@@ -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"
+41
View File
@@ -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
+64
View File
@@ -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
+15
View File
@@ -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