mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-05 10:29:58 +02:00
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:
79
apple/VniDrop/UI/Feedback/SnackbarHost.swift
Normal file
79
apple/VniDrop/UI/Feedback/SnackbarHost.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
78
apple/VniDrop/UI/Feedback/UiMessage.swift
Normal file
78
apple/VniDrop/UI/Feedback/UiMessage.swift
Normal 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))
|
||||
}
|
||||
}
|
||||
126
apple/VniDrop/UI/Feedback/UserFacingError.swift
Normal file
126
apple/VniDrop/UI/Feedback/UserFacingError.swift
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user