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:
31
apple/VniDrop/App/AppEnvironment.swift
Normal file
31
apple/VniDrop/App/AppEnvironment.swift
Normal 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
|
||||
}
|
||||
43
apple/VniDrop/App/AppGraph.swift
Normal file
43
apple/VniDrop/App/AppGraph.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
169
apple/VniDrop/App/RootView.swift
Normal file
169
apple/VniDrop/App/RootView.swift
Normal 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
|
||||
40
apple/VniDrop/App/VniDropApp.swift
Normal file
40
apple/VniDrop/App/VniDropApp.swift
Normal 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"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user