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
+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)
}
}
}