diff --git a/apps/ios/.gitignore b/apps/ios/.gitignore new file mode 100644 index 0000000..45e4fe4 --- /dev/null +++ b/apps/ios/.gitignore @@ -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/ diff --git a/apps/ios/Branding/make-icon.swift b/apps/ios/Branding/make-icon.swift new file mode 100644 index 0000000..bcce8ef --- /dev/null +++ b/apps/ios/Branding/make-icon.swift @@ -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)") diff --git a/apps/ios/GoBridge/go.mod b/apps/ios/GoBridge/go.mod new file mode 100644 index 0000000..acee453 --- /dev/null +++ b/apps/ios/GoBridge/go.mod @@ -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 => ../../.. diff --git a/apps/ios/GoBridge/go.sum b/apps/ios/GoBridge/go.sum new file mode 100644 index 0000000..003014b --- /dev/null +++ b/apps/ios/GoBridge/go.sum @@ -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= diff --git a/apps/ios/GoBridge/share.go b/apps/ios/GoBridge/share.go new file mode 100644 index 0000000..ad8dfc7 --- /dev/null +++ b/apps/ios/GoBridge/share.go @@ -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() + } +} diff --git a/apps/ios/README.md b/apps/ios/README.md new file mode 100644 index 0000000..3ec77fe --- /dev/null +++ b/apps/ios/README.md @@ -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 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 dev.tzgyn.share data) +cat "$DATA/Documents/share-selftest.txt" +``` + +Send (the app seeds a generated 2 MiB file): + +```sh +xcrun simctl launch 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 \ + --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. diff --git a/apps/ios/Share/AppModel.swift b/apps/ios/Share/AppModel.swift new file mode 100644 index 0000000..7e9f4f4 --- /dev/null +++ b/apps/ios/Share/AppModel.swift @@ -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? + + 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) + } +} diff --git a/apps/ios/Share/Assets.xcassets/AccentColor.colorset/Contents.json b/apps/ios/Share/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..b36b80f --- /dev/null +++ b/apps/ios/Share/Assets.xcassets/AccentColor.colorset/Contents.json @@ -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 + } +} diff --git a/apps/ios/Share/Assets.xcassets/AppIcon.appiconset/AppIcon.png b/apps/ios/Share/Assets.xcassets/AppIcon.appiconset/AppIcon.png new file mode 100644 index 0000000..a68029c Binary files /dev/null and b/apps/ios/Share/Assets.xcassets/AppIcon.appiconset/AppIcon.png differ diff --git a/apps/ios/Share/Assets.xcassets/AppIcon.appiconset/Contents.json b/apps/ios/Share/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..cefcc87 --- /dev/null +++ b/apps/ios/Share/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images" : [ + { + "filename" : "AppIcon.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/ios/Share/Assets.xcassets/Contents.json b/apps/ios/Share/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/apps/ios/Share/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/ios/Share/Components.swift b/apps/ios/Share/Components.swift new file mode 100644 index 0000000..602421c --- /dev/null +++ b/apps/ios/Share/Components.swift @@ -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: 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) + } +} diff --git a/apps/ios/Share/Info.plist b/apps/ios/Share/Info.plist new file mode 100644 index 0000000..ecdb352 --- /dev/null +++ b/apps/ios/Share/Info.plist @@ -0,0 +1,40 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Share + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + Share + CFBundlePackageType + APPL + CFBundleShortVersionString + 0.1.0 + CFBundleVersion + 1 + LSSupportsOpeningDocumentsInPlace + + NSLocalNetworkUsageDescription + Share connects directly to nearby devices on your local network. If a direct connection is not possible, it uses the relay instead. + UIFileSharingEnabled + + UILaunchScreen + + UIRequiresFullScreen + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + + UIUserInterfaceStyle + Dark + + diff --git a/apps/ios/Share/Models.swift b/apps/ios/Share/Models.swift new file mode 100644 index 0000000..3492319 --- /dev/null +++ b/apps/ios/Share/Models.swift @@ -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) + } +} diff --git a/apps/ios/Share/RoomView.swift b/apps/ios/Share/RoomView.swift new file mode 100644 index 0000000..9ed8376 --- /dev/null +++ b/apps/ios/Share/RoomView.swift @@ -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) + } +} diff --git a/apps/ios/Share/RootView.swift b/apps/ios/Share/RootView.swift new file mode 100644 index 0000000..7f1e328 --- /dev/null +++ b/apps/ios/Share/RootView.swift @@ -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) + } +} diff --git a/apps/ios/Share/SelfTest.swift b/apps/ios/Share/SelfTest.swift new file mode 100644 index 0000000..10290e0 --- /dev/null +++ b/apps/ios/Share/SelfTest.swift @@ -0,0 +1,180 @@ +import Foundation +import SwiftUI + +/// A headless-ish smoke test driven by launch arguments: +/// +/// xcrun simctl launch 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)") + } +} diff --git a/apps/ios/Share/SetupView.swift b/apps/ios/Share/SetupView.swift new file mode 100644 index 0000000..02c0857 --- /dev/null +++ b/apps/ios/Share/SetupView.swift @@ -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" + } +} diff --git a/apps/ios/Share/ShareApp.swift b/apps/ios/Share/ShareApp.swift new file mode 100644 index 0000000..2bc2a50 --- /dev/null +++ b/apps/ios/Share/ShareApp.swift @@ -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) + } + } +} diff --git a/apps/ios/build-go.sh b/apps/ios/build-go.sh new file mode 100755 index 0000000..f9fbad2 --- /dev/null +++ b/apps/ios/build-go.sh @@ -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" diff --git a/apps/ios/build.sh b/apps/ios/build.sh new file mode 100755 index 0000000..8dd2e2b --- /dev/null +++ b/apps/ios/build.sh @@ -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 diff --git a/apps/ios/project.yml b/apps/ios/project.yml new file mode 100644 index 0000000..af7fb4a --- /dev/null +++ b/apps/ios/project.yml @@ -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 diff --git a/apps/ios/run-device.sh b/apps/ios/run-device.sh new file mode 100755 index 0000000..9018d07 --- /dev/null +++ b/apps/ios/run-device.sh @@ -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