feat(apple): native SwiftUI app for iOS and macOS

Add a native SwiftUI VniDrop app (Send/Receive/Settings) talking to the
Rust core via generated UniFFI Swift bindings, plus the uniffi-bindgen
helper crate. iOS uses a TabView, macOS a NavigationSplitView sidebar.
This commit is contained in:
2026-07-18 22:06:30 +02:00
parent 601cbc486e
commit ea376a382b
68 changed files with 8650 additions and 1 deletions

View File

@@ -0,0 +1,79 @@
import SwiftUI
/// Bottom toast host driven by `UiMessageController`, ported from
/// `ui/feedback/VniDropSnackbarHost.kt`. Tone drives the accent color; errors get
/// a longer display duration.
struct SnackbarHost: View {
@ObservedObject var controller: UiMessageController
@State private var dismissTask: Task<Void, Never>?
var body: some View {
VStack {
Spacer()
if let message = controller.current {
content(for: message)
.frame(maxWidth: 520)
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 14))
.overlay(RoundedRectangle(cornerRadius: 14).stroke(.gray.opacity(0.25), lineWidth: 0.5))
.shadow(color: .black.opacity(0.15), radius: 8, y: 2)
.padding(.horizontal, 16)
.padding(.bottom, 8)
.transition(.move(edge: .bottom).combined(with: .opacity))
.id(message.id)
.onAppear { scheduleDismiss(for: message) }
}
}
.animation(.easeInOut(duration: 0.2), value: controller.current?.id)
}
@ViewBuilder
private func content(for message: UiMessage) -> some View {
let accent: Color = {
switch message.tone {
case .info: return VniDropColors.brandPurple
case .success: return .green
case .warning: return .orange
case .error: return .red
}
}()
HStack(alignment: .center, spacing: 8) {
Circle().fill(accent).frame(width: 8, height: 8)
Text(message.text.resolved())
.font(.subheadline)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.vertical, 10)
if let actionLabel = message.actionLabel {
Button(action: {
message.onAction?()
dismiss()
}) {
Text(actionLabel.resolved()).fontWeight(.semibold)
}
.buttonStyle(.borderless)
}
Button(action: dismiss) {
Image(systemName: "xmark")
.font(.footnote.weight(.semibold))
.foregroundStyle(.secondary)
.frame(width: 36, height: 36)
}
.buttonStyle(.plain)
}
.padding(.leading, 16)
.padding(.trailing, 4)
}
private func scheduleDismiss(for message: UiMessage) {
dismissTask?.cancel()
let seconds: UInt64 = message.tone == .error ? 6 : 4
dismissTask = Task {
try? await Task.sleep(nanoseconds: seconds * 1_000_000_000)
if !Task.isCancelled { controller.advance() }
}
}
private func dismiss() {
dismissTask?.cancel()
controller.advance()
}
}

View File

@@ -0,0 +1,78 @@
import Foundation
import Combine
/// A localizable UI string: either a catalog key or dynamic text, ported from
/// `UiText` in `ui/feedback/UiMessageController.kt`.
enum UiText: Equatable {
case resource(String) // Localizable.xcstrings key
case dynamic(String)
/// Resolves to display text. Keys go through the string catalog.
func resolved() -> String {
switch self {
case .dynamic(let value): return value
case .resource(let key): return String(localized: String.LocalizationValue(key))
}
}
}
enum UiMessageTone {
case info
case success
case warning
case error
}
struct UiMessage: Identifiable {
let id = UUID()
let text: UiText
var tone: UiMessageTone = .info
var actionLabel: UiText? = nil
var onAction: (() -> Void)? = nil
}
/// Queues user-facing messages (snackbars) and dismissal requests. Ported from
/// `UiMessageController.kt`. Errors that are user cancellations are suppressed.
@MainActor
final class UiMessageController: ObservableObject {
@Published private(set) var current: UiMessage?
private var queue: [UiMessage] = []
func show(_ message: UiMessage) {
if current == nil {
current = message
} else {
queue.append(message)
}
}
@discardableResult
func tryShow(_ message: UiMessage) -> Bool {
show(message)
return true
}
/// Called by the host when the current message is dismissed or times out.
func advance() {
if queue.isEmpty {
current = nil
} else {
current = queue.removeFirst()
}
}
/// Surfaces a user-facing error. Logs the technical detail; suppresses user
/// cancellations. Mirrors `UiMessageController.error(Throwable)`.
func error(_ error: Error) {
if error.isUserCancellation {
AppLogger.info("ui", "suppressed user cancellation", ["detail": error.technicalDetail])
return
}
AppLogger.error("ui", "user-facing error", error)
show(UiMessage(text: error.toUiText(), tone: .error))
}
func error(_ text: UiText) {
show(UiMessage(text: text, tone: .error))
}
}

View File

@@ -0,0 +1,126 @@
import Foundation
import VnidropCore
/// Maps technical failures to stable, user-facing catalog keys. Ported from
/// `ui/feedback/UserFacingError.kt`. Never exposes raw `reason=` blobs.
extension Error {
func toUiText() -> UiText {
if let vni = self as? VnidropError {
switch vni {
case .Ticket:
return .resource("error_invalid_ticket")
case .Permission:
return .resource("error_permission")
case .Filesystem:
return .resource("error_filesystem")
case .Transfer(let reason):
return transferUiText(reason)
case .Repository:
return .resource("error_repository")
case .Initialization(let reason):
return initializationUiText(reason)
case .Internal(let reason):
return reasonHints(reason) ?? .resource("error_generic")
}
}
return reasonHints(technicalDetail) ?? .resource("error_generic")
}
/// True when the user intentionally backed out of a flow.
var isUserCancellation: Bool {
let haystack = technicalDetail.lowercased()
if haystack.isEmpty {
// URLError / CocoaError cancellation without a message.
if let urlError = self as? URLError, urlError.code == .cancelled { return true }
return (self as NSError).code == NSUserCancelledError
}
return haystack.contains("cancelled")
|| haystack.contains("canceled")
|| haystack.contains("user cancelled")
|| haystack.contains("user canceled")
}
/// Prefers a `VnidropError` reason; else the localized description.
var technicalDetail: String {
if let vni = self as? VnidropError {
switch vni {
case .Initialization(let r), .Ticket(let r), .Filesystem(let r),
.Transfer(let r), .Permission(let r), .Repository(let r), .Internal(let r):
return r
}
}
return (self as? LocalizedError)?.errorDescription ?? (self as NSError).localizedDescription
}
}
private func transferUiText(_ reason: String) -> UiText {
let detail = reason.lowercased()
if detail.contains("refused") || detail.contains("denied") || detail.contains("not approved") {
return .resource("error_permission")
}
return .resource("error_transfer")
}
private func initializationUiText(_ reason: String) -> UiText {
let detail = reason.lowercased()
if detail.contains("native") && detail.contains("library") {
return .resource("error_missing_native_library")
}
if detail.contains("socket") || detail.contains("bind") {
return .resource("error_socket_bind")
}
return .resource("error_initialization")
}
private func reasonHints(_ detailRaw: String) -> UiText? {
let detail = detailRaw.lowercased()
if detail.isEmpty { return nil }
if detail.contains("still starting") || detail.contains("starting up") {
return .resource("error_starting_up")
}
if detail.contains("empty") && (detail.contains("invitation") || detail.contains("ticket") || detail.contains("qr")) {
return .resource("error_invitation_empty")
}
if detail.contains("select at least one") || detail.contains("no files found") {
return .resource("error_share_empty")
}
if detail.contains("camera") {
return .resource("error_camera")
}
if detail.contains("nfc") || detail.contains("ndef")
|| (detail.contains("read-only") && detail.contains("tag"))
|| detail.contains("tag is too small") || detail.contains("no nfc tag") {
return .resource("error_nfc")
}
if detail.contains("native") && detail.contains("library") {
return .resource("error_missing_native_library")
}
if detail.contains("socket") || detail.contains("bind") {
return .resource("error_socket_bind")
}
if detail.contains("device information") || detail.contains("device info") {
return .resource("error_device_info")
}
if detail.contains("refused") || detail.contains("denied") || detail.contains("permission")
|| detail.contains("not approved") || detail.contains("waiting for approval") {
return .resource("error_permission")
}
if detail.contains("invalid ticket") || detail.contains("ticket error")
|| detail.contains("could not be read") || detail.contains("malformed")
|| detail.contains("invitation could not be opened") {
return .resource("error_invalid_ticket")
}
if detail.contains("selected")
&& (detail.contains("file") || detail.contains("folder") || detail.contains("document") || detail.contains("open")) {
return .resource("error_selection_failed")
}
if detail.contains("could not open the selected") || detail.contains("could not open selected") {
return .resource("error_selection_failed")
}
if detail.contains("document picker") || detail.contains("folder picker") || detail.contains("file descriptor")
|| detail.contains("view controller") {
return .resource("error_selection_failed")
}
return nil
}