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,31 @@
import Foundation
/// Platform environment, ported from `Platform.kt` (`PlatformEnvironment`).
struct PlatformEnvironment {
let name: String
let appVersion: String
let defaultCoreDataDir: String
var defaultUsername: String = "Receiver"
}
/// Device info for diagnostics/about, ported from `DeviceInfo`.
struct DeviceInfo {
let deviceName: String?
let deviceModel: String?
let operatingSystem: String
let network: String?
let batteryLevel: String?
}
protocol DeviceInfoProvider {
func load() async -> DeviceInfo
}
/// Bundle of platform dependencies, ported from `AppDependencies`.
struct AppDependencies {
let environment: PlatformEnvironment
let deviceInfoProvider: DeviceInfoProvider
let fileSystemService: FileSystemService
let notificationService: LocalNotificationService
let externalInvitations: ExternalInvitationController
}

View File

@@ -0,0 +1,43 @@
import Foundation
import Combine
/// Object graph wiring the repositories and coordinators together, ported from
/// `AppGraph.kt`. Owned by the app root for the process lifetime.
@MainActor
final class AppGraph: ObservableObject {
let dependencies: AppDependencies
let coreRepository: CoreRepository
let visibility = AppVisibility()
let messages = UiMessageController()
let preferencesRepository: AppPreferencesRepository
let filePreviewRepository: FilePreviewRepository
let approvalCoordinator: ApprovalCoordinator
init(dependencies: AppDependencies, coreRepository: CoreRepository? = nil) {
self.dependencies = dependencies
let coreRepository = coreRepository ?? CoreRepository()
self.coreRepository = coreRepository
self.filePreviewRepository = FilePreviewRepository(appDataDir: dependencies.environment.defaultCoreDataDir)
self.preferencesRepository = AppPreferencesRepository(
fallback: AppPreferencesDefaults(
username: dependencies.environment.defaultUsername,
receiveFolder: dependencies.fileSystemService.defaultReceiveFolder(),
themeMode: .system,
notificationsEnabled: false,
diagnosticsEnabled: false
)
)
self.approvalCoordinator = ApprovalCoordinator(
repository: coreRepository,
preferences: preferencesRepository,
notifications: dependencies.notificationService,
visibility: visibility,
messages: messages
)
AppLogger.info("lifecycle", "graph created", ["platform": dependencies.environment.name])
}
func close() {
coreRepository.shutdown()
}
}

View File

@@ -0,0 +1,169 @@
import SwiftUI
/// App root, ported from `App.kt`. Owns the object graph and feature models, wires
/// the adaptive shell, floating actions, snackbar host, and approval modal.
struct RootView: View {
@StateObject private var graph: AppGraph
@StateObject private var appModel: AppModel
@StateObject private var sendModel: SendModel
@StateObject private var receiveModel: ReceiveModel
@StateObject private var settingsModel: SettingsModel
@ObservedObject private var messages: UiMessageController
@ObservedObject private var approvals: ApprovalCoordinator
@Environment(\.scenePhase) private var scenePhase
init(dependencies: AppDependencies) {
let graph = AppGraph(dependencies: dependencies)
_graph = StateObject(wrappedValue: graph)
_appModel = StateObject(wrappedValue: AppModel(
environment: dependencies.environment,
repository: graph.coreRepository,
preferences: graph.preferencesRepository,
messages: graph.messages
))
_sendModel = StateObject(wrappedValue: SendModel(
repository: graph.coreRepository,
fileSystemService: dependencies.fileSystemService,
preferences: graph.preferencesRepository,
filePreviewRepository: graph.filePreviewRepository,
messages: graph.messages
))
_receiveModel = StateObject(wrappedValue: ReceiveModel(
repository: graph.coreRepository,
fileSystemService: dependencies.fileSystemService,
preferences: graph.preferencesRepository,
messages: graph.messages
))
_settingsModel = StateObject(wrappedValue: SettingsModel(
environment: dependencies.environment,
deviceInfoProvider: dependencies.deviceInfoProvider,
fileSystemService: dependencies.fileSystemService,
preferences: graph.preferencesRepository,
notifications: dependencies.notificationService,
messages: graph.messages,
bugReports: NoopBugReportService()
))
messages = graph.messages
approvals = graph.approvalCoordinator
}
var body: some View {
GeometryReader { proxy in
let windowClass = windowClassFor(width: proxy.size.width)
let isDark = resolveDarkTheme(appModel.themeMode, systemDark: systemDark)
ZStack {
navigation(windowClass: windowClass)
SnackbarHost(controller: messages)
ApprovalModalHost(
state: approvals.state,
onAccept: approvals.accept,
onRefuse: approvals.refuse
)
}
.vniDropTheme(isDark: isDark)
.preferredColorScheme(appModel.themeMode.preferredColorScheme)
.environment(\.vniColors, isDark ? .dark : .light)
}
.platformPickers(settingsModel: settingsModel)
.task { await consumeExternalInvitations() }
.onChange(of: scenePhase) { phase in
switch phase {
case .active:
graph.visibility.setForeground(true)
settingsModel.refreshNotificationPermission()
// Reconcile against the durable snapshot: while the window was
// unfocused/occluded (common on macOS) live events may not have
// rendered, leaving progress/status stale.
Task { _ = await graph.coreRepository.refresh() }
case .background, .inactive:
graph.visibility.setForeground(false)
@unknown default:
break
}
}
}
/// iOS uses a bottom tab bar; macOS uses a native source-list sidebar so each
/// screen's toolbar lives in the detail column instead of the shared title bar.
@ViewBuilder
private func navigation(windowClass: WindowClass) -> some View {
#if os(macOS)
NavigationSplitView {
List(AppDestination.allCases, selection: sidebarBinding) { destination in
Label(LocalizedStringKey(destination.labelKey), systemImage: destination.systemImage)
.tag(destination)
}
.navigationSplitViewColumnWidth(min: 180, ideal: 200, max: 260)
} detail: {
screen(for: appModel.destination, windowClass: windowClass)
}
#else
TabView(selection: destinationBinding) {
ForEach(AppDestination.allCases) { destination in
screen(for: destination, windowClass: windowClass)
.tabItem {
Label(LocalizedStringKey(destination.labelKey), systemImage: destination.systemImage)
}
.tag(destination)
}
}
#endif
}
private var sidebarBinding: Binding<AppDestination?> {
Binding(
get: { appModel.destination },
set: { newValue in
if let value = newValue {
Task { @MainActor in appModel.selectDestination(value) }
}
}
)
}
private var destinationBinding: Binding<AppDestination> {
// Defer the write out of the current view-update cycle: TabView reconciles
// its selection synchronously during body evaluation on macOS, and mutating
// the published `destination` there triggers a "publishing within view
// updates" warning.
Binding(get: { appModel.destination }, set: { newValue in
Task { @MainActor in appModel.selectDestination(newValue) }
})
}
@ViewBuilder
private func screen(for destination: AppDestination, windowClass: WindowClass) -> some View {
switch destination {
case .send: SendScreen(model: sendModel, windowClass: windowClass)
case .receive: ReceiveScreen(model: receiveModel, windowClass: windowClass)
case .settings: SettingsScreen(model: settingsModel, windowClass: windowClass)
}
}
private var systemDark: Bool {
#if os(iOS)
return UITraitCollection.current.userInterfaceStyle == .dark
#else
return NSApp.effectiveAppearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua
#endif
}
private func consumeExternalInvitations() async {
for await invitation in graph.dependencies.externalInvitations.invitations {
appModel.selectDestination(.receive)
switch invitation {
case .success(let raw):
receiveModel.onInvitationResult(.invitationFile, .success(raw))
case .failure(let error):
receiveModel.onInvitationResult(.invitationFile, .failure(error))
}
}
}
}
#if os(iOS)
import UIKit
#else
import AppKit
#endif

View File

@@ -0,0 +1,40 @@
import SwiftUI
/// App entry point for iOS/iPadOS/macOS, ported from `iOSApp.swift` + `App.kt`.
/// Opens `.vnd` invitations via `onOpenURL` and routes them to the receive flow.
@main
struct VniDropApp: App {
@StateObject private var externalInvitations = ExternalInvitationController()
var body: some Scene {
WindowGroup {
RootView(dependencies: makeAppDependencies(externalInvitations: externalInvitations))
.ignoresSafeArea()
.onOpenURL(perform: openInvitation)
}
}
/// Reads a `.vnd` invitation document under a security scope, enforcing the
/// 64 KiB / strict-UTF-8 rules from `ContentView.swift`.
private func openInvitation(_ url: URL) {
guard url.pathExtension.caseInsensitiveCompare(vniDropInvitationExtension) == .orderedSame else {
externalInvitations.reportOpenFailure(message: "This is not a VniDrop invitation")
return
}
let started = url.startAccessingSecurityScopedResource()
defer { if started { url.stopAccessingSecurityScopedResource() } }
do {
let values = try url.resourceValues(forKeys: [.fileSizeKey])
if let size = values.fileSize, size > maxVniDropInvitationBytes {
throw InvitationError.tooLarge
}
let data = try Data(contentsOf: url, options: .mappedIfSafe)
let raw = try decodeInvitationBytes(data)
externalInvitations.openInvitation(raw: raw)
} catch {
externalInvitations.reportOpenFailure(
message: (error as? LocalizedError)?.errorDescription ?? "The invitation could not be opened"
)
}
}
}