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

54
Cargo.lock generated
View File

@@ -61,6 +61,12 @@ dependencies = [
"libc",
]
[[package]]
name = "anstyle"
version = "1.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
[[package]]
name = "anyhow"
version = "1.0.103"
@@ -496,6 +502,45 @@ dependencies = [
"inout",
]
[[package]]
name = "clap"
version = "4.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dd059f9da4f5c36b3787f65d38ccaab1cc315f07b01f89abc8359ee6a8205011"
dependencies = [
"clap_builder",
"clap_derive",
]
[[package]]
name = "clap_builder"
version = "4.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f09628afdcc538b57f3c6341e9c8e9970f18e4a481690a64974d7023bd33548b"
dependencies = [
"anstyle",
"clap_lex",
"strsim",
]
[[package]]
name = "clap_derive"
version = "4.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9"
dependencies = [
"heck",
"proc-macro2",
"quote",
"syn 2.0.118",
]
[[package]]
name = "clap_lex"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
[[package]]
name = "cmov"
version = "0.5.4"
@@ -4835,13 +4880,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6d968cb62160c11f2573e6be724ef8b1b18a277aededd17033f8a912d73e2b4"
dependencies = [
"anyhow",
"camino",
"cargo_metadata",
"clap",
"uniffi_bindgen",
"uniffi_core",
"uniffi_macros",
"uniffi_pipeline",
]
[[package]]
name = "uniffi-bindgen"
version = "0.1.0"
dependencies = [
"uniffi",
]
[[package]]
name = "uniffi_bindgen"
version = "0.29.4"

View File

@@ -1,5 +1,5 @@
[workspace]
members = ["crates/vnidrop"]
members = ["crates/vnidrop", "crates/uniffi-bindgen"]
resolver = "2"
[profile.dev]

13
apple/.gitignore vendored Normal file
View File

@@ -0,0 +1,13 @@
# Generated by apple/scripts/build-core.sh
.build-core/
VnidropCore/vnidrop.xcframework/
VnidropCore/Sources/VnidropCore/Vnidrop.swift
# Generated by XcodeGen from project.yml
VniDrop.xcodeproj/
# SwiftPM / Xcode
.build/
.swiftpm/
DerivedData/
*.xcuserstate

43
apple/Package.swift Normal file
View File

@@ -0,0 +1,43 @@
// swift-tools-version:5.9
import PackageDescription
// Core/UI Swift sources built as a library so the shared logic can be typechecked
// and unit-tested from the command line (macOS). The iOS/macOS app target in the
// Xcode project links the same sources plus the app entry point.
let package = Package(
name: "VniDropApp",
defaultLocalization: "en",
platforms: [
.iOS(.v16),
.macOS(.v13),
],
products: [
.library(name: "VniDropApp", targets: ["VniDropApp"]),
],
dependencies: [
.package(path: "VnidropCore"),
],
targets: [
.target(
name: "VniDropApp",
dependencies: [.product(name: "VnidropCore", package: "VnidropCore")],
path: "VniDrop",
// The @main entry belongs to the Xcode app target only; excluding it
// keeps this library free of a conflicting `_main` symbol for tests.
exclude: ["Resources", "App/VniDropApp.swift"],
// The Rust core (iroh network stack) links these system libraries. The
// Xcode app target must add the same frameworks under "Link Binary With
// Libraries" (SystemConfiguration, Security, libresolv).
linkerSettings: [
.linkedFramework("SystemConfiguration"),
.linkedFramework("Security"),
.linkedLibrary("resolv"),
]
),
.testTarget(
name: "VniDropAppTests",
dependencies: ["VniDropApp"],
path: "Tests"
),
]
)

93
apple/README.md Normal file
View File

@@ -0,0 +1,93 @@
# VniDrop — native Apple app (iOS / iPadOS / macOS)
A native SwiftUI app for Apple platforms, sharing the existing Rust transfer core
(`crates/vnidrop`) through UniFFI-generated Swift bindings. The Rust crate is not
modified; the Kotlin/Compose app layer is ported to Swift and mirrors the Compose
UI screen-for-screen. Android and desktop JVM continue to use `shared/` + Compose.
## Layout
```
apple/
scripts/build-core.sh # builds the Rust core + generates Swift bindings + xcframework
VnidropCore/ # local SwiftPM package: xcframework + generated Vnidrop.swift
VniDrop/ # SwiftUI app sources
App/ # entry point, object graph, root view, environment
Core/ # repository, models, preferences, notifications, progress
Features/Send|Receive|Approvals|Settings/
UI/Theme|Components|Navigation|Feedback|Shell/
Platform/ # pickers, QR, NFC, share/export, per-OS file services
Resources/ # Localizable.xcstrings, Info.plist, entitlements, assets
Tests/ # XCTest (ported progress-derivation assertions)
Package.swift # builds VniDrop/ as a library for CLI build/test
project.yml # XcodeGen spec for the iOS/macOS app target
```
## Build & run
Prerequisites: Xcode, Rust with the Apple targets
(`aarch64-apple-ios`, `aarch64-apple-ios-sim`, `x86_64-apple-ios`,
`aarch64-apple-darwin`), and `xcodegen` (`brew install xcodegen`).
```bash
# 1. Build the Rust core and generate the Swift bindings + xcframework.
apple/scripts/build-core.sh debug # or: release (see note below)
# 2. Generate the Xcode project.
cd apple && xcodegen generate
# 3. Open and run, or build from the CLI:
open VniDrop.xcodeproj
# macOS:
xcodebuild -project VniDrop.xcodeproj -scheme VniDrop -destination 'platform=macOS' build
# iOS simulator:
xcodebuild -project VniDrop.xcodeproj -scheme VniDrop \
-destination 'platform=iOS Simulator,name=iPhone 15' build
```
## Command-line typecheck & tests
`Package.swift` builds the same sources as a library (minus the `@main` entry),
so the shared logic can be checked and unit-tested without Xcode:
```bash
cd apple
swift build # macOS
swift test # runs Tests/ (ported progress-derivation assertions)
# iOS typecheck:
swift build --triple arm64-apple-ios16.0-simulator --sdk "$(xcrun --sdk iphonesimulator --show-sdk-path)"
```
## Generated / ignored artifacts
`build-core.sh` produces build outputs that are gitignored (see `apple/.gitignore`):
`VnidropCore/vnidrop.xcframework/`, `VnidropCore/Sources/VnidropCore/Vnidrop.swift`,
and `.build-core/`. A clean checkout must run `build-core.sh` before generating or
opening the Xcode project. `VniDrop.xcodeproj` itself is generated by XcodeGen from
`project.yml` and does not need to be committed.
## Build profile note
The default is `debug`. The workspace `[profile.release]` uses thin LTO, which the
current macOS toolchain miscompiles into corrupt host proc-macro dylibs
("mis-aligned LINKEDIT string pool"). `build-core.sh` sets
`CARGO_PROFILE_DEV_STRIP=none` (matching the existing Gobley Xcode run-script) so
debug builds succeed. For a release core, disable LTO for proc-macros/build
scripts (e.g. add a `[profile.release.build-override] lto = false` locally) — the
Rust crate itself is never changed.
## System frameworks
The Rust core (iroh network stack) links `SystemConfiguration`, `Security`, and
`libresolv`. These are declared in both `Package.swift` (for CLI build/test) and
`project.yml` (for the app target).
## Parity & scope
Screens mirror the Compose UI in `shared/`. Two deliberate simplifications:
- Empty-state Lottie animations are rendered as SF Symbols (no `lottie-ios`
dependency); swap in `lottie-ios` if exact-parity animation is required.
- The full diagnostics/telemetry stack (`diagnostics/*`) is stubbed behind
`BugReportService` / `DiagnosticsBuildConfig` and lands in a later phase; the UI
hides the diagnostics toggle when not compiled in.
```

View File

@@ -0,0 +1,59 @@
import XCTest
@testable import VniDropApp
/// Ports selected `shared/src/commonTest/.../ui/state` assertions to verify the
/// progress-derivation logic matches the Kotlin implementation.
final class ProgressDerivationTests: XCTestCase {
func testFormatBytes() {
XCTAssertEqual(formatBytes(0), "0 B")
XCTAssertEqual(formatBytes(1023), "1023 B")
XCTAssertEqual(formatBytes(1024), "1.0 KB")
XCTAssertEqual(formatBytes(1536), "1.5 KB")
XCTAssertEqual(formatBytes(1024 * 1024), "1.0 MB")
}
func testWindowClassThresholds() {
XCTAssertEqual(windowClassFor(width: 320), .phone)
XCTAssertEqual(windowClassFor(width: 599), .phone)
XCTAssertEqual(windowClassFor(width: 600), .tablet)
XCTAssertEqual(windowClassFor(width: 919), .tablet)
XCTAssertEqual(windowClassFor(width: 920), .desktop)
}
func testParseProgressPrefersExported() {
XCTAssertEqual(parseProgress("{\"exported\":50,\"file_size\":100}"), 0.5)
XCTAssertEqual(parseProgress("{\"downloaded\":25,\"total_size\":100}"), 0.25)
XCTAssertNil(parseProgress("{\"foo\":1}"))
XCTAssertEqual(parseProgress("{\"offset\":200,\"size\":100}"), 1.0) // clamped
}
func testFindStringSkipsNull() {
XCTAssertEqual(findString("{\"endpoint_id\":\"abc\"}", key: "endpoint_id"), "abc")
XCTAssertNil(findString("{\"endpoint_id\":null}", key: "endpoint_id"))
XCTAssertNil(findString("{\"endpoint_id\":123}", key: "endpoint_id"))
}
func testProgressForTransferUsesLatestNewestFirst() {
let events = [
event(phase: "import", kind: "copy-progress", json: "{\"exported\":30,\"file_size\":100}"),
event(phase: "import", kind: "started", json: "{}"),
]
let progress = progressForTransfer(events: events, transferId: 1)
XCTAssertEqual(progress?.labelKey, "progress_preparing")
XCTAssertEqual(progress?.progress, 0.3)
}
func testStatusLabelKeys() {
XCTAssertEqual(statusLabelKey(.sharing), "status_available")
XCTAssertEqual(statusLabelKey(.receiving), "status_receiving")
XCTAssertEqual(statusLabelKey(.done), "status_completed")
}
private func event(phase: String, kind: String, json: String) -> CoreEventModel {
CoreEventModel(
id: UUID().uuidString, timestamp: 0, scope: "transfer", transferId: 1,
direction: "send", phase: phase, kind: kind, dataJson: json
)
}
}

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

View File

@@ -0,0 +1,24 @@
import Foundation
import os
/// Minimal structured logger, ported from `logging/AppLogger.kt`. Never logs
/// tickets, endpoint ids, or file contents (callers pass only redacted fields).
enum AppLogger {
private static let logger = Logger(subsystem: "com.vnidrop.app", category: "app")
static func info(_ scope: String, _ message: String, _ fields: [String: String] = [:]) {
logger.info("[\(scope, privacy: .public)] \(message, privacy: .public) \(fieldString(fields), privacy: .public)")
}
static func error(_ scope: String, _ message: String, _ error: Error? = nil) {
if let error {
logger.error("[\(scope, privacy: .public)] \(message, privacy: .public): \(error.technicalDetail, privacy: .public)")
} else {
logger.error("[\(scope, privacy: .public)] \(message, privacy: .public)")
}
}
private static func fieldString(_ fields: [String: String]) -> String {
fields.isEmpty ? "" : fields.map { "\($0)=\($1)" }.joined(separator: " ")
}
}

View File

@@ -0,0 +1,135 @@
import Foundation
import Combine
/// Receive-destination descriptor, ported from `core/FileSystemService.kt`.
enum ReceiveFolderKind: String, Codable {
case fileSystemPath
case iosSecurityScopedUrl
}
struct ReceiveFolder: Equatable, Codable {
let kind: ReceiveFolderKind
let value: String
let displayName: String
}
enum FolderAccessStatus {
case writable
case permissionRequired
case unavailable
}
/// Persisted app preferences, ported from `preferences/AppPreferencesRepository.kt`.
/// Backed by `UserDefaults` instead of DataStore; keys and semantics match.
struct AppPreferences: Equatable {
var username: String
var receiveFolder: ReceiveFolder
var themeMode: ThemeMode
var notificationsEnabled: Bool
var diagnosticsEnabled: Bool
var diagnosticsInstallId: String
}
struct AppPreferencesDefaults {
let username: String
let receiveFolder: ReceiveFolder
let themeMode: ThemeMode
var notificationsEnabled: Bool = false
var diagnosticsEnabled: Bool = false
}
@MainActor
final class AppPreferencesRepository: ObservableObject {
@Published private(set) var preferences: AppPreferences
private let defaults: UserDefaults
private let fallback: AppPreferencesDefaults
private enum Key {
static let username = "username"
static let receiveFolderKind = "receive_folder_kind"
static let receiveFolderValue = "receive_folder_value"
static let receiveFolderDisplayName = "receive_folder_display_name"
static let themeMode = "theme_mode"
static let notificationsEnabled = "notifications_enabled"
static let diagnosticsEnabled = "diagnostics_enabled"
static let diagnosticsInstallId = "diagnostics_install_id"
}
init(defaults: UserDefaults = .standard, fallback: AppPreferencesDefaults) {
self.defaults = defaults
self.fallback = fallback
self.preferences = Self.read(from: defaults, fallback: fallback)
}
private static func read(from defaults: UserDefaults, fallback: AppPreferencesDefaults) -> AppPreferences {
let username = (defaults.string(forKey: Key.username)).flatMap { $0.isEmpty ? nil : $0 } ?? fallback.username
let folder = resolveReceiveFolder(defaults, fallback: fallback.receiveFolder)
let themeMode = defaults.string(forKey: Key.themeMode).flatMap(ThemeMode.init(rawValue:)) ?? fallback.themeMode
let notifications = defaults.object(forKey: Key.notificationsEnabled) as? Bool ?? fallback.notificationsEnabled
let diagnostics = defaults.object(forKey: Key.diagnosticsEnabled) as? Bool ?? fallback.diagnosticsEnabled
let installId = defaults.string(forKey: Key.diagnosticsInstallId) ?? ""
return AppPreferences(
username: username,
receiveFolder: folder,
themeMode: themeMode,
notificationsEnabled: notifications,
diagnosticsEnabled: diagnostics,
diagnosticsInstallId: installId
)
}
private static func resolveReceiveFolder(_ defaults: UserDefaults, fallback: ReceiveFolder) -> ReceiveFolder {
let kind = defaults.string(forKey: Key.receiveFolderKind)
.flatMap(ReceiveFolderKind.init(rawValue:)) ?? fallback.kind
let value = defaults.string(forKey: Key.receiveFolderValue).flatMap { $0.isEmpty ? nil : $0 } ?? fallback.value
let displayName = defaults.string(forKey: Key.receiveFolderDisplayName)
.flatMap { $0.isEmpty ? nil : $0 } ?? fallback.displayName
return ReceiveFolder(kind: kind, value: value, displayName: displayName)
}
private func reload() {
preferences = Self.read(from: defaults, fallback: fallback)
}
func setUsername(_ username: String) {
defaults.set(username.trimmingCharacters(in: .whitespacesAndNewlines), forKey: Key.username)
reload()
}
func setReceiveFolder(_ folder: ReceiveFolder) {
defaults.set(folder.kind.rawValue, forKey: Key.receiveFolderKind)
defaults.set(folder.value, forKey: Key.receiveFolderValue)
defaults.set(folder.displayName, forKey: Key.receiveFolderDisplayName)
reload()
}
func resetReceiveFolder() {
setReceiveFolder(fallback.receiveFolder)
}
func setThemeMode(_ mode: ThemeMode) {
defaults.set(mode.rawValue, forKey: Key.themeMode)
reload()
}
func setNotificationsEnabled(_ enabled: Bool) {
defaults.set(enabled, forKey: Key.notificationsEnabled)
reload()
}
func setDiagnosticsEnabled(_ enabled: Bool) {
defaults.set(enabled, forKey: Key.diagnosticsEnabled)
reload()
}
@discardableResult
func ensureDiagnosticsInstallId() -> String {
let existing = preferences.diagnosticsInstallId
if !existing.isEmpty { return existing }
let created = UUID().uuidString
defaults.set(created, forKey: Key.diagnosticsInstallId)
reload()
return created
}
}

View File

@@ -0,0 +1,12 @@
import Foundation
import Combine
/// Tracks whether the app is in the foreground, ported from `platform/AppVisibility.kt`.
@MainActor
final class AppVisibility: ObservableObject {
@Published private(set) var isForeground: Bool = true
func setForeground(_ value: Bool) {
isForeground = value
}
}

View File

@@ -0,0 +1,141 @@
import Foundation
/// App-facing domain models, ported from `core/CoreModels.kt`. The repository maps
/// the generated UniFFI records/enums into these so the UI never depends on the
/// binding surface directly.
struct CoreStatus: Equatable {
let endpointId: String
let activeTransfers: UInt64
let activeShares: UInt64
}
struct CoreEventModel: Equatable, Identifiable {
let id: String
let timestamp: Int64
let scope: String
let transferId: UInt64?
let direction: String?
let phase: String
let kind: String
let dataJson: String
}
enum ShareAccessPolicy: Equatable {
case requireApproval
case anyoneWithTransfer
}
enum TransferDirection: Equatable {
case send
case receive
}
enum TransferStatus: Equatable {
case importing
case sharing
case receiving
case done
case failed
case cancelled
case stopped
}
struct Transfer: Equatable, Identifiable {
let localId: String
let transferId: UInt64
let direction: TransferDirection
let status: TransferStatus
let peerId: String?
let transferName: String?
let contentHash: String?
let fileCount: UInt64
let totalSize: UInt64
let ticket: String?
let accessPolicy: ShareAccessPolicy
let createdAt: Int64
let updatedAt: Int64
var id: String { localId }
}
struct Share: Equatable {
let transferId: UInt64
let ticket: String
let transferName: String
let contentHash: String
let fileCount: UInt64
let totalSize: UInt64
}
struct TransferMetadataModel: Equatable {
let transferId: UInt64
let transferName: String
let senderName: String?
let contentHash: String
let fileCount: UInt64
let totalSize: UInt64
}
struct TicketInspectionModel: Equatable {
let kind: String
let metadata: TransferMetadataModel
}
enum ReceiverDeliveryStatus: Equatable {
case requested
case accepted
case refused
case expired
case completed
case unknown
}
struct ReceiverRequestModel: Equatable, Identifiable {
let id: String
let transferId: UInt64
let remoteEndpointId: String
let transferName: String
let receiverName: String?
let receiverDeviceName: String?
let appVersion: String
let status: ReceiverDeliveryStatus
let reason: String?
let requestedAt: Int64
let respondedAt: Int64?
let completedAt: Int64?
}
struct CoreState: Equatable {
var isInitialized: Bool = false
var status: CoreStatus?
var events: [CoreEventModel] = []
var transfers: [Transfer] = []
var lastShare: Share?
var lastInspection: TicketInspectionModel?
}
/// Coalesced change hints emitted from the event sink, ported from `CoreSignal`.
enum CoreSignal: Equatable {
case approvalChanged(transferId: UInt64)
case receiverHistoryChanged(transferId: UInt64)
/// Transfer status/history changed enough to re-read the durable snapshot.
case transfersChanged(transferId: UInt64)
}
// MARK: - Transfer helpers (ported from AppUiModels.kt)
extension TransferStatus {
var isActiveTransfer: Bool {
self == .importing || self == .sharing || self == .receiving
}
var canCancelTransfer: Bool {
self == .importing || self == .sharing || self == .receiving
}
/// Terminal receive-history states eligible for deletion.
var isTerminalReceiveHistory: Bool {
self == .done || self == .failed || self == .cancelled
}
}

View File

@@ -0,0 +1,392 @@
import Foundation
import Combine
import VnidropCore
/// Swift port of `core/CoreRepository.kt`. Owns the `VnidropCore` handle, maps the
/// generated UniFFI records into app domain models, publishes an observable
/// `CoreState`, and emits coalesced `CoreSignal`s from the event sink.
///
/// UniFFI calls block (the core drives its own runtime via `block_on`), so they
/// run on a background queue and results are hopped back to the main actor.
@MainActor
final class CoreRepository: ObservableObject {
@Published private(set) var state = CoreState()
private let signalsSubject = PassthroughSubject<CoreSignal, Never>()
/// Coalesced change hints; subscribe to react to approval/history/transfer changes.
var signals: AnyPublisher<CoreSignal, Never> { signalsSubject.eraseToAnyPublisher() }
private var core: VnidropCore?
private let queue = DispatchQueue(label: "com.vnidrop.core", qos: .userInitiated)
private lazy var sink = RepositoryEventSink { [weak self] event in
Task { @MainActor in self?.handle(event: event) }
}
private static let maxEvents = 200
// MARK: - Lifecycle
func initialize(appDataDir: String) async -> Result<Void, Error> {
await runCore { [sink] in
self.core?.shutdown()
let created = try VnidropCore.initialize(appDataDir: appDataDir, eventSink: sink)
return created
}.map { created in
self.core = created
self.refreshSnapshot()
self.state.isInitialized = true
}
}
func shutdown() {
core?.shutdown()
core = nil
state = CoreState()
}
// MARK: - Share
func shareSources(
_ sources: [ShareSource],
transferName: String,
senderName: String,
accessPolicy: ShareAccessPolicy
) async -> Result<Share, Error> {
guard !sources.isEmpty else {
return .failure(InvitationError.message("Select at least one file to share"))
}
return await runCore {
let result = try self.requireCore().shareFiles(
sources: sources,
metadata: ShareMetadataInput(
transferId: Self.nextTransferId(),
transferName: transferName.isEmpty ? nil : transferName,
senderName: senderName.isEmpty ? nil : senderName,
accessMode: accessPolicy.toNative()
)
)
return result.toModel()
}.map { share in
self.refreshSnapshot()
self.state.lastShare = share
return share
}
}
// MARK: - Inspect / Receive
func inspectTicket(_ ticket: String) async -> Result<TicketInspectionModel, Error> {
await runCore {
try self.requireCore().inspectTicket(ticket: ticket).toModel()
}.map { inspection in
self.state.lastInspection = inspection
return inspection
}
}
func receive(ticket: String, outputDir: String, receiverName: String) async -> Result<Void, Error> {
await runCore {
try self.requireCore().receive(
ticket: ticket,
outputDir: outputDir,
receiverName: receiverName.isEmpty ? nil : receiverName
)
}.map { self.refreshSnapshot() }
}
/// Receive into a security-scoped directory URL, holding access while the core
/// streams (mirrors `receiveIntoSecurityScopedDirectory`).
func receiveIntoSecurityScopedDirectory(
ticket: String,
outputDirectoryUrl: String,
receiverName: String
) async -> Result<Void, Error> {
await runCore {
try withSecurityScopedAccess(pathOrUrl: outputDirectoryUrl) {
try self.requireCore().receive(
ticket: ticket,
outputDir: outputDirectoryUrl,
receiverName: receiverName.isEmpty ? nil : receiverName
)
}
}.map { self.refreshSnapshot() }
}
// MARK: - Lifecycle actions
func cancel(transferId: UInt64) async -> Result<Void, Error> {
await runCore {
try self.requireCore().cancelTransfer(transferId: transferId)
}.map { self.refreshSnapshot() }
}
func delete(transferId: UInt64) async -> Result<Void, Error> {
await runCore {
try self.requireCore().deleteTransfer(transferId: transferId)
}.map {
self.refreshSnapshot()
self.signalsSubject.send(.approvalChanged(transferId: transferId))
self.signalsSubject.send(.receiverHistoryChanged(transferId: transferId))
}
}
func clearReceiveHistory() async -> Result<UInt64, Error> {
await runCore {
try self.requireCore().deleteReceiveHistory()
}.map { deleted in
self.refreshSnapshot()
return deleted
}
}
func receiverRequests(transferId: UInt64) async -> Result<[ReceiverRequestModel], Error> {
await runCore {
try self.requireCore().listReceiverRequests(transferId: transferId).map { $0.toModel() }
}
}
func respondReceiverRequest(
requestId: String,
accepted: Bool,
reason: String? = nil
) async -> Result<Void, Error> {
await runCore {
try self.requireCore().respondReceiverRequest(requestId: requestId, accepted: accepted, reason: reason)
}
}
func refresh() async -> Result<Void, Error> {
// Read from the core off the main actor, then apply the snapshot on the
// main actor so `@Published state` is never mutated from `queue`.
await runCore { self.readSnapshot() }.map { snapshot in
if let snapshot { self.applySnapshot(snapshot) }
}
}
// MARK: - Event sink handling (ported from CoreRepository.sink)
private func handle(event: CoreEvent) {
let model = event.toModel()
var events = state.events
events.insert(model, at: 0)
if events.count > Self.maxEvents { events = Array(events.prefix(Self.maxEvents)) }
state.events = events
guard let transferId = model.transferId else { return }
switch model.phase {
case "approval": signalsSubject.send(.approvalChanged(transferId: transferId))
case "delivery": signalsSubject.send(.receiverHistoryChanged(transferId: transferId))
default: break
}
if model.shouldRefreshTransfers {
signalsSubject.send(.transfersChanged(transferId: transferId))
}
}
// MARK: - Internals
/// Snapshot of the values read from the core in one pass.
private struct CoreSnapshot {
let status: CoreStatus
let transfers: [Transfer]
let events: [CoreEventModel]
}
/// Reads the current core state. Safe to call off the main actor (pure core
/// FFI reads); does not touch `@Published` state.
private func readSnapshot() -> CoreSnapshot? {
guard let core = self.core else { return nil }
let status = core.status()
let transfers = (try? core.listTransfers())?.map { $0.toModel() } ?? state.transfers
let events = (try? core.listEvents(transferId: nil))?.prefix(Self.maxEvents).map { $0.toModel() } ?? state.events
return CoreSnapshot(
status: CoreStatus(
endpointId: status.endpointId,
activeTransfers: status.activeTransfers,
activeShares: status.activeShares
),
transfers: transfers,
events: Array(events)
)
}
/// Applies a snapshot to `@Published state`. Must run on the main actor.
private func applySnapshot(_ snapshot: CoreSnapshot) {
state.status = snapshot.status
state.transfers = snapshot.transfers
state.events = snapshot.events
}
private func refreshSnapshot() {
if let snapshot = readSnapshot() { applySnapshot(snapshot) }
}
private func requireCore() throws -> VnidropCore {
guard let core = self.core else {
throw InvitationError.message("Initialize the core first.")
}
return core
}
/// Runs a blocking core call off the main actor and hops the result back.
private func runCore<T>(_ block: @escaping () throws -> T) async -> Result<T, Error> {
await withCheckedContinuation { continuation in
queue.async {
let result: Result<T, Error>
do {
result = .success(try block())
} catch {
result = .failure(error)
}
continuation.resume(returning: result)
}
}
}
private static func nextTransferId() -> UInt64 {
UInt64.random(in: 1...UInt64(Int64.max))
}
}
/// Event sink bridged to the repository. `onEvent` is invoked on core-owned
/// threads; the handler hops to the main actor.
private final class RepositoryEventSink: CoreEventSink, @unchecked Sendable {
private let handler: @Sendable (CoreEvent) -> Void
init(handler: @escaping @Sendable (CoreEvent) -> Void) { self.handler = handler }
func onEvent(event: CoreEvent) { handler(event) }
}
/// Runs `body` while holding security-scoped access to a bookmarked URL/path.
private func withSecurityScopedAccess<T>(pathOrUrl: String, _ body: () throws -> T) throws -> T {
let url = URL(string: pathOrUrl) ?? URL(fileURLWithPath: pathOrUrl)
let started = url.startAccessingSecurityScopedResource()
defer { if started { url.stopAccessingSecurityScopedResource() } }
return try body()
}
// MARK: - Mapping (ported from CoreRepository.kt)
private extension CoreEvent {
func toModel() -> CoreEventModel {
CoreEventModel(
id: id, timestamp: timestamp, scope: scope, transferId: transferId,
direction: direction, phase: phase, kind: kind, dataJson: dataJson
)
}
}
private let refreshPhases: Set<String> = ["lifecycle", "error", "ticket", "import", "download", "export", "handshake"]
private let refreshKinds: Set<String> = [
"started", "done", "created", "failed", "cancelled", "share-stopped", "found-collection", "connected",
]
private extension CoreEventModel {
var shouldRefreshTransfers: Bool {
refreshPhases.contains(phase) && refreshKinds.contains(kind)
}
}
extension ShareAccessPolicy {
func toNative() -> TransferAccessMode {
switch self {
case .requireApproval: return .approvalRequired
case .anyoneWithTransfer: return .public
}
}
}
private extension TransferAccessMode {
func toModel() -> ShareAccessPolicy {
switch self {
case .approvalRequired: return .requireApproval
case .public: return .anyoneWithTransfer
}
}
}
private extension StoredTransfer {
func toModel() -> Transfer {
Transfer(
localId: localId,
transferId: transferId,
direction: Self.direction(direction),
status: Self.status(status),
peerId: peerId,
transferName: transferName,
contentHash: contentHash,
fileCount: fileCount,
totalSize: totalSize,
ticket: ticket,
accessPolicy: accessMode.toModel(),
createdAt: createdAt,
updatedAt: updatedAt
)
}
static func direction(_ raw: String) -> TransferDirection {
switch raw {
case "send": return .send
case "receive": return .receive
default: return .send
}
}
static func status(_ raw: String) -> TransferStatus {
switch raw {
case "importing": return .importing
case "sharing": return .sharing
case "receiving": return .receiving
case "done": return .done
case "failed": return .failed
case "cancelled": return .cancelled
case "stopped": return .stopped
default: return .failed
}
}
}
private extension ShareResult {
func toModel() -> Share {
Share(
transferId: transferId, ticket: ticket, transferName: transferName,
contentHash: hash, fileCount: fileCount, totalSize: totalSize
)
}
}
private extension TicketInspection {
func toModel() -> TicketInspectionModel {
TicketInspectionModel(kind: kind, metadata: metadata.toModel())
}
}
private extension TransferMetadata {
func toModel() -> TransferMetadataModel {
TransferMetadataModel(
transferId: transferId, transferName: transferName, senderName: senderName,
contentHash: contentHash, fileCount: fileCount, totalSize: totalSize
)
}
}
private extension ReceiverRequest {
func toModel() -> ReceiverRequestModel {
ReceiverRequestModel(
id: id, transferId: transferId, remoteEndpointId: remoteEndpointId,
transferName: transferName, receiverName: receiverName, receiverDeviceName: receiverDeviceName,
appVersion: appVersion, status: Self.status(status), reason: reason,
requestedAt: requestedAt, respondedAt: respondedAt, completedAt: completedAt
)
}
static func status(_ raw: String) -> ReceiverDeliveryStatus {
switch raw {
case "requested": return .requested
case "accepted": return .accepted
case "refused": return .refused
case "expired": return .expired
case "completed": return .completed
default: return .unknown
}
}
}

View File

@@ -0,0 +1,66 @@
import Foundation
let vniDropInvitationMimeType = "application/vnd.vnidrop.transfer"
let vniDropInvitationExtension = "vnd"
let maxVniDropInvitationBytes = 64 * 1024
/// Buffered ingress for invitation documents opened by the OS, ported from
/// `ExternalInvitationController.kt`. Hosts can submit before the UI is attached
/// during a cold launch; each document is consumed exactly once.
@MainActor
final class ExternalInvitationController: ObservableObject {
/// Emits validated (or failed) invitations. The app-level receive workflow
/// consumes each exactly once.
private var continuation: AsyncStream<Result<String, Error>>.Continuation?
lazy var invitations: AsyncStream<Result<String, Error>> = {
AsyncStream { continuation in
self.continuation = continuation
}
}()
func openInvitation(raw: String) {
continuation?.yield(validateInvitation(raw))
}
func reportOpenFailure(message: String) {
continuation?.yield(.failure(InvitationError.message(message)))
}
}
enum InvitationError: LocalizedError {
case empty
case tooLarge
case invalidEncoding
case message(String)
var errorDescription: String? {
switch self {
case .empty: return "The invitation is empty"
case .tooLarge: return "The invitation is too large"
case .invalidEncoding: return "The invitation is not valid text"
case .message(let m): return m
}
}
}
func validateInvitation(_ raw: String) -> Result<String, Error> {
if raw.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
return .failure(InvitationError.empty)
}
if raw.utf8.count > maxVniDropInvitationBytes {
return .failure(InvitationError.tooLarge)
}
return .success(raw)
}
/// Decode invitation document bytes as strict UTF-8, ported from
/// `decodeInvitationBytes`. Rejects payloads that are not lossless UTF-8.
func decodeInvitationBytes(_ bytes: Data) throws -> String {
guard !bytes.isEmpty else { throw InvitationError.empty }
guard bytes.count <= maxVniDropInvitationBytes else { throw InvitationError.tooLarge }
guard let text = String(data: bytes, encoding: .utf8), Data(text.utf8) == bytes else {
throw InvitationError.invalidEncoding
}
guard !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { throw InvitationError.empty }
return text
}

View File

@@ -0,0 +1,58 @@
import Foundation
import VnidropCore
/// A file/folder selected for sharing, ported from `PickedShareFile` in
/// `core/FilePicker.kt`.
struct PickedShareFile: Equatable, Identifiable {
let value: String
let displayName: String
var sizeBytes: UInt64? = nil
var thumbnailData: Data? = nil
/// App-owned picker copy that may be deleted after import or abandonment.
var isTemporaryCopy: Bool = false
/// When true, `value` is a directory (path or security-scoped folder URL).
var isDirectory: Bool = false
var id: String { value }
}
/// Receive-destination and share-source platform bridge, ported from
/// `core/FileSystemService.kt` and its iOS/desktop actuals.
protocol FileSystemService {
var supportsCustomReceiveFolders: Bool { get }
func defaultReceiveFolder() -> ReceiveFolder
func effectiveReceiveFolder(_ configured: ReceiveFolder) -> ReceiveFolder
func validateReceiveFolder(_ folder: ReceiveFolder) async -> FolderAccessStatus
func canRevealReceiveFolder(_ folder: ReceiveFolder) -> Bool
func revealReceiveFolder(_ folder: ReceiveFolder) async -> Result<Void, Error>
/// Releases only app-owned picker copies; never deletes original user sources.
func discardPickedFiles(_ files: [PickedShareFile]) async
func sharePickedFiles(
repository: CoreRepository,
files: [PickedShareFile],
transferName: String,
senderName: String,
accessPolicy: ShareAccessPolicy
) async -> Result<Share, Error>
}
extension FileSystemService {
var supportsCustomReceiveFolders: Bool { true }
func effectiveReceiveFolder(_ configured: ReceiveFolder) -> ReceiveFolder {
supportsCustomReceiveFolders ? configured : defaultReceiveFolder()
}
func canRevealReceiveFolder(_ folder: ReceiveFolder) -> Bool { false }
func revealReceiveFolder(_ folder: ReceiveFolder) async -> Result<Void, Error> {
.failure(InvitationError.message("Revealing the receive folder is not supported"))
}
func discardPickedFiles(_ files: [PickedShareFile]) async {}
}
extension ReceiveFolder {
var isFileSystemPath: Bool { kind == .fileSystemPath }
}

View File

@@ -0,0 +1,103 @@
import Foundation
import Combine
import UserNotifications
/// Notification permission state, ported from `NotificationPermission`.
enum NotificationPermission {
case notDetermined
case granted
case denied
case unsupported
}
struct LocalNotification {
let id: String
let title: String
let body: String
}
/// Local notification service, ported from `LocalNotificationService.kt` /
/// `.ios.kt`, backed by `UNUserNotificationCenter`.
@MainActor
final class LocalNotificationService: ObservableObject {
@Published private(set) var permission: NotificationPermission = .notDetermined
private let center = UNUserNotificationCenter.current()
func refreshPermission() async -> NotificationPermission {
let settings = await center.notificationSettings()
let mapped = Self.map(settings.authorizationStatus)
permission = mapped
return mapped
}
func requestPermission() async -> NotificationPermission {
do {
_ = try await center.requestAuthorization(options: [.alert, .sound, .badge])
} catch {
AppLogger.error("notifications", "authorization request failed", error)
}
return await refreshPermission()
}
func openSettings() async -> Result<Void, Error> {
#if os(iOS)
guard let url = URL(string: UIApplication.openSettingsURLString) else {
return .failure(NotificationError.settingsUnavailable)
}
let opened = await UIApplication.shared.open(url)
return opened ? .success(()) : .failure(NotificationError.settingsUnavailable)
#else
guard let url = URL(string: "x-apple.systempreferences:com.apple.preference.notifications") else {
return .failure(NotificationError.settingsUnavailable)
}
NSWorkspace.shared.open(url)
return .success(())
#endif
}
@discardableResult
func publish(_ notification: LocalNotification) async -> Result<Void, Error> {
let content = UNMutableNotificationContent()
content.title = notification.title
content.body = notification.body
content.sound = .default
let request = UNNotificationRequest(identifier: notification.id, content: content, trigger: nil)
do {
try await center.add(request)
return .success(())
} catch {
return .failure(error)
}
}
func cancel(id: String) {
center.removePendingNotificationRequests(withIdentifiers: [id])
center.removeDeliveredNotifications(withIdentifiers: [id])
}
func cancelAll() {
center.removeAllPendingNotificationRequests()
center.removeAllDeliveredNotifications()
}
private static func map(_ status: UNAuthorizationStatus) -> NotificationPermission {
switch status {
case .authorized, .provisional, .ephemeral: return .granted
case .denied: return .denied
case .notDetermined: return .notDetermined
@unknown default: return .notDetermined
}
}
}
private enum NotificationError: LocalizedError {
case settingsUnavailable
var errorDescription: String? { "Could not open notification settings" }
}
#if os(iOS)
import UIKit
#else
import AppKit
#endif

View File

@@ -0,0 +1,330 @@
import Foundation
/// Window size classes, ported from `AppUiModels.kt`. Thresholds are in points,
/// matching the Compose dp thresholds.
enum WindowClass {
case phone
case tablet
case desktop
}
func windowClassFor(width: Double) -> WindowClass {
if width >= 920 { return .desktop }
if width >= 600 { return .tablet }
return .phone
}
/// A progress snapshot derived from core events. `label` is a localization key
/// resolved at the view layer.
struct TransferProgress: Equatable {
let transferId: UInt64?
let phase: String
let kind: String
let labelKey: String
let progress: Double?
var detail: String? = nil
}
func statusLabelKey(_ status: TransferStatus) -> String {
switch status {
case .importing: return "status_preparing"
case .sharing: return "status_available"
case .receiving: return "status_receiving"
case .done: return "status_completed"
case .cancelled: return "status_cancelled"
case .stopped: return "status_stopped"
case .failed: return "status_failed"
}
}
private let progressPhases: Set<String> = [
"import", "ticket", "access", "transfer", "download", "export",
"lifecycle", "network", "handshake", "error",
]
private let progressKinds: Set<String> = [
"started", "copy-progress", "copy-done", "outboard-progress", "done",
"created", "progress", "completed", "aborted", "failed",
"connecting", "connected", "found-collection",
"cancelled", "share-stopped",
]
/// Latest progress snapshot for a transfer. Events are newest-first.
func progressForTransfer(events: [CoreEventModel], transferId: UInt64) -> TransferProgress? {
let relevant = events.filter { event in
event.transferId == transferId
&& progressPhases.contains(event.phase)
&& progressKinds.contains(event.kind)
}
guard let latest = relevant.first else { return nil }
let sizeHint = findKnownSize(events: events, transferId: transferId)
return TransferProgress(
transferId: transferId,
phase: latest.phase,
kind: latest.kind,
labelKey: humanProgressLabel(latest),
progress: parseProgress(latest.dataJson, sizeHint: sizeHint),
detail: progressDetail(latest)
)
}
/// Live byte progress for one receiver on an outgoing share.
func progressForReceiver(
events: [CoreEventModel],
transferId: UInt64,
remoteEndpointId: String,
totalSizeHint: UInt64? = nil
) -> TransferProgress? {
if remoteEndpointId.isEmpty { return nil }
let connectionIds = connectionIdsForEndpoint(events: events, remoteEndpointId: remoteEndpointId)
let transferEvents = events.filter { event in
event.transferId == transferId
&& event.direction == "send"
&& event.phase == "transfer"
&& ["started", "progress", "completed", "aborted"].contains(event.kind)
&& eventBelongsToReceiver(event, remoteEndpointId: remoteEndpointId, connectionIds: connectionIds)
}
if transferEvents.isEmpty { return nil }
let latest = transferEvents[0]
if latest.kind == "aborted" {
return TransferProgress(
transferId: transferId, phase: "transfer", kind: "aborted",
labelKey: "progress_interrupted", progress: nil, detail: nil
)
}
if latest.kind == "completed" && !transferEvents.contains(where: { $0.kind == "progress" || $0.kind == "started" }) {
return TransferProgress(
transferId: transferId, phase: "transfer", kind: "completed",
labelKey: "progress_completed", progress: 1, detail: nil
)
}
let progress = aggregateReceiverProgress(events: transferEvents, totalSizeHint: totalSizeHint)
return TransferProgress(
transferId: transferId, phase: "transfer", kind: latest.kind,
labelKey: "progress_sending", progress: progress, detail: progressDetail(latest)
)
}
/// Best send-side progress for a transfer (any receiver).
func activeSendProgress(
events: [CoreEventModel],
transferId: UInt64,
totalSizeHint: UInt64? = nil
) -> TransferProgress? {
let endpointIds = events
.filter { $0.transferId == transferId && $0.direction == "send" && $0.phase == "transfer" }
.compactMap { findString($0.dataJson, key: "endpoint_id") }
.reduce(into: [String]()) { acc, id in if !acc.contains(id) { acc.append(id) } }
if endpointIds.isEmpty {
let relevant = events.filter {
$0.transferId == transferId && $0.direction == "send" && $0.phase == "transfer"
&& ["started", "progress"].contains($0.kind)
}
guard let first = relevant.first else { return nil }
return TransferProgress(
transferId: transferId, phase: "transfer", kind: first.kind,
labelKey: "progress_sending",
progress: aggregateReceiverProgress(events: relevant, totalSizeHint: totalSizeHint),
detail: progressDetail(first)
)
}
let all = endpointIds.compactMap {
progressForReceiver(events: events, transferId: transferId, remoteEndpointId: $0, totalSizeHint: totalSizeHint)
}
return all.first { $0.kind == "progress" || $0.kind == "started" } ?? all.first
}
func formatBytes(_ size: UInt64) -> String {
var scaled = Double(size)
let units = ["B", "KB", "MB", "GB", "TB"]
var unitIndex = 0
while scaled >= 1024 && unitIndex < units.count - 1 {
scaled /= 1024
unitIndex += 1
}
if unitIndex == 0 {
return "\(size) \(units[unitIndex])"
}
let rounded = (scaled * 10).rounded() / 10
return "\(rounded) \(units[unitIndex])"
}
// MARK: - Internals (ported literally from AppUiModels.kt)
private func humanProgressLabel(_ event: CoreEventModel) -> String {
switch (event.phase, event.kind) {
case ("import", "copy-progress"), ("import", "outboard-progress"), ("import", "started"):
return "progress_preparing"
case ("import", "done"): return "progress_ready"
case ("ticket", "created"): return "progress_share_ready"
case ("network", "connecting"): return "progress_connecting"
case ("network", "connected"): return "progress_connected"
case ("download", "found-collection"): return "progress_getting_ready"
case ("download", "progress"): return "progress_downloading"
case ("export", "progress"): return "progress_saving"
case ("transfer", "progress"): return "progress_sending"
case ("transfer", "started"): return "progress_connected"
case ("transfer", "completed"): return "progress_completed"
case ("lifecycle", "done"): return "progress_completed"
case ("lifecycle", "cancelled"): return "progress_cancelled"
default:
if event.phase == "handshake" { return "progress_requesting_access" }
if event.kind == "failed" { return "progress_failed" }
return "progress_working"
}
}
private func progressDetail(_ event: CoreEventModel) -> String? {
let fileName = findString(event.dataJson, key: "file_name")
let current = findNumber(event.dataJson, key: "current_file_index").map { Int64($0) }
let totalFiles = findNumber(event.dataJson, key: "total_files").map { Int64($0) }
if let fileName, let current, let totalFiles, totalFiles > 0 {
return "\(fileName) (\(current + 1)/\(totalFiles))"
}
return fileName
}
func parseProgress(_ json: String, sizeHint: Double? = nil) -> Double? {
let transferred = findNumber(json, key: "exported")
?? findNumber(json, key: "downloaded")
?? findNumber(json, key: "offset")
?? findNumber(json, key: "end_offset")
?? findNumber(json, key: "transferred")
?? findNumber(json, key: "written")
let total = findNumber(json, key: "file_size")
?? findNumber(json, key: "total_size")
?? findNumber(json, key: "size")
?? findNumber(json, key: "total")
?? sizeHint
guard let transferred, let total, total > 0 else { return nil }
return min(1, max(0, transferred / total))
}
private func findKnownSize(events: [CoreEventModel], transferId: UInt64) -> Double? {
for event in events where event.transferId == transferId {
if let s = findNumber(event.dataJson, key: "size"), s > 0 { return s }
if let s = findNumber(event.dataJson, key: "total_size"), s > 0 { return s }
if let s = findNumber(event.dataJson, key: "file_size"), s > 0 { return s }
}
return nil
}
private final class BlobState {
var size: Double?
var offset: Double = 0
var completed = false
var aborted = false
}
private func aggregateReceiverProgress(events: [CoreEventModel], totalSizeHint: UInt64?) -> Double? {
let chronological = events.reversed()
var byRequest = [String: BlobState]()
var order = [String]()
var connectionScopedOffset: Double?
var connectionScopedSize: Double?
for event in chronological {
let requestKey = findNumber(event.dataJson, key: "request_id").map { String(Int64($0)) }
?? findString(event.dataJson, key: "request_id")
let size = findNumber(event.dataJson, key: "size")
let endOffset = findNumber(event.dataJson, key: "end_offset")
?? findNumber(event.dataJson, key: "offset")
?? findNumber(event.dataJson, key: "transferred")
if let requestKey {
let state: BlobState
if let existing = byRequest[requestKey] {
state = existing
} else {
state = BlobState()
byRequest[requestKey] = state
order.append(requestKey)
}
if let size, size > 0 { state.size = size }
switch event.kind {
case "progress", "started":
if let endOffset { state.offset = max(state.offset, endOffset) }
state.aborted = false
case "completed":
state.completed = true
if let s = state.size { state.offset = s }
case "aborted":
state.aborted = true
default:
break
}
} else {
if let size, size > 0 { connectionScopedSize = size }
if let endOffset { connectionScopedOffset = endOffset }
}
}
if !byRequest.isEmpty {
let active = order.compactMap { byRequest[$0] }.filter { !$0.aborted }
if active.isEmpty { return nil }
let transferred = active.reduce(0.0) { acc, state in
acc + (state.completed ? (state.size ?? state.offset) : state.offset)
}
let observedSize = active.compactMap { $0.size }.reduce(0, +)
let total = totalSizeHint.map { Double($0) }.flatMap { $0 > 0 ? $0 : nil }
?? (observedSize > 0 ? observedSize : nil)
guard let total, total > 0 else { return nil }
return min(1, max(0, transferred / total))
}
let total = totalSizeHint.map { Double($0) }.flatMap { $0 > 0 ? $0 : nil }
?? connectionScopedSize.flatMap { $0 > 0 ? $0 : nil }
guard let transferred = connectionScopedOffset, let total, total > 0 else { return nil }
return min(1, max(0, transferred / total))
}
private func connectionIdsForEndpoint(events: [CoreEventModel], remoteEndpointId: String) -> Set<String> {
var ids = Set<String>()
for event in events {
guard let endpoint = findString(event.dataJson, key: "endpoint_id"), endpoint == remoteEndpointId else { continue }
if let n = findNumber(event.dataJson, key: "connection_id") { ids.insert(String(Int64(n))) }
if let s = findString(event.dataJson, key: "connection_id") { ids.insert(s) }
}
return ids
}
private func eventBelongsToReceiver(_ event: CoreEventModel, remoteEndpointId: String, connectionIds: Set<String>) -> Bool {
if let endpoint = findString(event.dataJson, key: "endpoint_id") {
return endpoint == remoteEndpointId
}
let connectionId = findNumber(event.dataJson, key: "connection_id").map { String(Int64($0)) }
?? findString(event.dataJson, key: "connection_id")
guard let connectionId else { return false }
return connectionIds.contains(connectionId)
}
// Lightweight JSON scraping, ported from AppUiModels.kt (matches core event shapes).
func findNumber(_ json: String, key: String) -> Double? {
let marker = "\"\(key)\":"
guard let range = json.range(of: marker) else { return nil }
let after = json[range.upperBound...].drop { $0 == " " }
if after.hasPrefix("null") { return nil }
let terminators: Set<Character> = [",", "}", "]"]
var value = ""
for ch in json[range.upperBound...] {
if terminators.contains(ch) { break }
value.append(ch)
}
let trimmed = value.trimmingCharacters(in: .whitespaces).trimmingCharacters(in: CharacterSet(charactersIn: "\""))
return Double(trimmed)
}
func findString(_ json: String, key: String) -> String? {
let marker = "\"\(key)\":"
guard let range = json.range(of: marker) else { return nil }
let after = json[range.upperBound...].drop { $0 == " " }
if after.hasPrefix("null") { return nil }
guard after.hasPrefix("\"") else { return nil }
let content = after.dropFirst()
guard let endIdx = content.firstIndex(of: "\"") else { return nil }
if content.startIndex == endIdx { return nil }
return String(content[content.startIndex..<endIdx])
}

View File

@@ -0,0 +1,44 @@
import Foundation
import Combine
/// Top-level app state, ported from `feature/app/AppViewModel.kt`. Initializes the
/// core on launch and tracks the selected destination + theme.
@MainActor
final class AppModel: ObservableObject {
@Published private(set) var destination: AppDestination = .send
@Published private(set) var themeMode: ThemeMode = .system
private let environment: PlatformEnvironment
private let repository: CoreRepository
private let messages: UiMessageController
private var cancellables = Set<AnyCancellable>()
init(
environment: PlatformEnvironment,
repository: CoreRepository,
preferences: AppPreferencesRepository,
messages: UiMessageController
) {
self.environment = environment
self.repository = repository
self.messages = messages
AppLogger.info("lifecycle", "app started", ["platform": environment.name])
Task {
let result = await repository.initialize(appDataDir: environment.defaultCoreDataDir)
if case .failure(let error) = result { messages.error(error) }
}
preferences.$preferences
.map(\.themeMode)
.removeDuplicates()
.sink { [weak self] mode in self?.themeMode = mode }
.store(in: &cancellables)
}
func selectDestination(_ destination: AppDestination) {
guard destination != self.destination else { return }
self.destination = destination
}
}

View File

@@ -0,0 +1,179 @@
import Foundation
import Combine
/// A pending receiver approval, ported from `feature/approvals/ApprovalCoordinator.kt`.
struct PendingApproval: Equatable, Identifiable {
let id: String
let transferId: UInt64
let transferName: String
let receiverName: String?
let receiverDeviceName: String?
/// Cryptographic peer identity not display-name spoofable.
let remoteEndpointId: String
let requestedAt: Int64
}
struct ApprovalState: Equatable {
var pending: [PendingApproval] = []
var respondingIds: Set<String> = []
var current: PendingApproval? { pending.first }
}
/// Drives receiver-approval prompts and their notifications, ported from
/// `ApprovalCoordinator.kt`.
@MainActor
final class ApprovalCoordinator: ObservableObject {
@Published private(set) var state = ApprovalState()
private let repository: CoreRepository
private let preferences: AppPreferencesRepository
private let notifications: LocalNotificationService
private let visibility: AppVisibility
private let messages: UiMessageController
private var publishedNotificationIds = Set<String>()
private var cancellables = Set<AnyCancellable>()
init(
repository: CoreRepository,
preferences: AppPreferencesRepository,
notifications: LocalNotificationService,
visibility: AppVisibility,
messages: UiMessageController
) {
self.repository = repository
self.preferences = preferences
self.notifications = notifications
self.visibility = visibility
self.messages = messages
repository.signals
.sink { [weak self] signal in
guard let self else { return }
if case .approvalChanged(let transferId) = signal {
Task { await self.refresh(transferId: transferId) }
}
}
.store(in: &cancellables)
repository.$state
.sink { [weak self] core in
guard let self, core.isInitialized else { return }
let sharing = core.transfers.filter { $0.direction == .send && $0.status == .sharing }
for transfer in sharing {
Task { await self.refresh(transferId: transfer.transferId) }
}
}
.store(in: &cancellables)
// Recompute notifications when any input changes.
Publishers.CombineLatest4(
preferences.$preferences,
visibility.$isForeground,
$state,
notifications.$permission
)
.sink { [weak self] preferences, foreground, approvalState, permission in
guard let self else { return }
Task {
await self.synchronizeNotifications(
enabled: preferences.notificationsEnabled,
foreground: foreground,
pending: approvalState.pending,
permission: permission
)
}
}
.store(in: &cancellables)
}
func accept(_ requestId: String) { respond(requestId, accepted: true) }
func refuse(_ requestId: String) { respond(requestId, accepted: false) }
private func respond(_ requestId: String, accepted: Bool) {
guard !state.respondingIds.contains(requestId) else { return }
state.respondingIds.insert(requestId)
Task {
let request = state.pending.first { $0.id == requestId }
let result = await repository.respondReceiverRequest(
requestId: requestId,
accepted: accepted,
reason: accepted ? nil : "sender-refused"
)
state.respondingIds.remove(requestId)
switch result {
case .success:
if let request { await refresh(transferId: request.transferId) }
case .failure(let error):
messages.error(error)
}
}
}
private func refresh(transferId: UInt64) async {
let result = await repository.receiverRequests(transferId: transferId)
switch result {
case .success(let requests):
let refreshed = requests
.filter { $0.status == .requested }
.map { $0.toPending() }
let refreshedIds = Set(refreshed.map { $0.id })
let removed = Set(state.pending.filter { $0.transferId == transferId }.map { $0.id })
.subtracting(refreshedIds)
for id in removed {
notifications.cancel(id: Self.notificationId(id))
publishedNotificationIds.remove(id)
}
var pending = state.pending.filter { $0.transferId != transferId } + refreshed
// distinctBy id, sorted by requestedAt
var seen = Set<String>()
pending = pending.filter { seen.insert($0.id).inserted }
.sorted { $0.requestedAt < $1.requestedAt }
state.pending = pending
case .failure(let error):
messages.error(error)
}
}
private func synchronizeNotifications(
enabled: Bool,
foreground: Bool,
pending: [PendingApproval],
permission: NotificationPermission
) async {
if foreground || !enabled || permission != .granted {
notifications.cancelAll()
return
}
for request in pending where !publishedNotificationIds.contains(request.id) {
let receiver = request.receiverName
?? request.receiverDeviceName
?? String(localized: "approval_nearby_device")
let title = String(localized: "approval_connection_request")
let body = String(
format: String(localized: "approval_request_body"),
receiver, request.transferName
)
let result = await notifications.publish(
LocalNotification(id: Self.notificationId(request.id), title: title, body: body)
)
switch result {
case .success: publishedNotificationIds.insert(request.id)
case .failure(let error): messages.error(error)
}
}
}
private static func notificationId(_ requestId: String) -> String { "approval-\(requestId)" }
}
private extension ReceiverRequestModel {
func toPending() -> PendingApproval {
PendingApproval(
id: id, transferId: transferId, transferName: transferName,
receiverName: receiverName, receiverDeviceName: receiverDeviceName,
remoteEndpointId: remoteEndpointId, requestedAt: requestedAt
)
}
}

View File

@@ -0,0 +1,73 @@
import SwiftUI
/// Non-dismissable receiver-approval modal, presented as a native sheet that can't
/// be swiped away. The endpoint id is the trusted identity; display names are
/// peer-provided.
struct ApprovalModalHost: View {
let state: ApprovalState
let onAccept: (String) -> Void
let onRefuse: (String) -> Void
var body: some View {
Color.clear
.sheet(isPresented: .constant(state.current != nil)) {
if let request = state.current {
ApprovalSheet(state: state, request: request, onAccept: onAccept, onRefuse: onRefuse)
.interactiveDismissDisabled(true)
.modifier(ApprovalDetents())
}
}
}
}
private struct ApprovalDetents: ViewModifier {
func body(content: Content) -> some View {
#if os(iOS)
content.presentationDetents([.medium])
#else
content.frame(minWidth: 420, minHeight: 320)
#endif
}
}
private struct ApprovalSheet: View {
let state: ApprovalState
let request: PendingApproval
let onAccept: (String) -> Void
let onRefuse: (String) -> Void
var body: some View {
let busy = state.respondingIds.contains(request.id)
let receiver = request.receiverName ?? request.receiverDeviceName ?? String(localized: "approval_nearby_device")
VStack(spacing: 16) {
Image(systemName: "checkmark.shield.fill")
.font(.system(size: 44))
.foregroundStyle(.tint)
.padding(.top, 12)
Text(LocalizedStringKey("approval_connection_request"))
.font(.title2).fontWeight(.semibold)
Text(String(format: String(localized: "approval_request_body"), receiver, request.transferName))
.multilineTextAlignment(.center)
Text(String(format: String(localized: "approval_endpoint_id"), request.remoteEndpointId))
.font(.caption).foregroundStyle(.secondary)
.multilineTextAlignment(.center)
if state.pending.count > 1 {
Text(String(format: String(localized: "approval_pending_count"), state.pending.count))
.font(.caption).foregroundStyle(.secondary)
}
Spacer(minLength: 0)
if busy { ProgressView() }
VStack(spacing: 10) {
Button(action: { onAccept(request.id) }) {
Text(LocalizedStringKey("button_approve")).frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent).controlSize(.large).disabled(busy)
Button(role: .destructive, action: { onRefuse(request.id) }) {
Text(LocalizedStringKey("button_refuse")).frame(maxWidth: .infinity)
}
.buttonStyle(.bordered).controlSize(.large).disabled(busy)
}
}
.padding(24)
}
}

View File

@@ -0,0 +1,146 @@
import SwiftUI
enum ReceiveMethodAvailability { case available, unavailable, hidden }
/// Invitation acquisition actions, ported from `ReceiveInvitationActions` (iosMain).
@MainActor
protocol ReceiveInvitationActions: AnyObject {
var fileAvailability: ReceiveMethodAvailability { get }
var qrAvailability: ReceiveMethodAvailability { get }
var nfcAvailability: ReceiveMethodAvailability { get }
func pickInvitation(onResult: @escaping (Result<String, Error>) -> Void)
func scanQrCode(onResult: @escaping (Result<String, Error>) -> Void)
func readNfcInvitation(onResult: @escaping (Result<String, Error>) -> Void)
func cancel()
}
/// Method chooser panel, ported from `ReceiveMethodPanel` in `ReceiveScreen.kt`.
struct ReceiveMethodPanel: View {
@Environment(\.vniColors) private var colors
@ObservedObject var model: ReceiveModel
@State private var actions: ReceiveInvitationActions = makeReceiveInvitationActions()
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text(LocalizedStringKey("receive_choose_method_title")).font(VniType.titleLarge)
Text(LocalizedStringKey("receive_choose_method_body")).foregroundStyle(colors.foregroundLighter)
MethodRow(
icon: "doc", titleKey: "receive_method_file", descKey: "receive_method_file_description",
availability: actions.fileAvailability
) { actions.pickInvitation { model.onInvitationResult(.invitationFile, $0) } }
if actions.qrAvailability != .hidden {
MethodRow(
icon: "qrcode.viewfinder", titleKey: "receive_method_scan", descKey: "receive_method_scan_description",
availability: actions.qrAvailability
) { actions.scanQrCode { model.onInvitationResult(.qrCode, $0) } }
}
if actions.nfcAvailability != .hidden {
MethodRow(
icon: "wave.3.right",
titleOverride: model.state.isWaitingForNfc ? String(localized: "receive_nfc_waiting") : nil,
titleKey: "receive_method_nfc", descKey: "receive_method_nfc_description",
availability: model.state.isWaitingForNfc ? .unavailable : actions.nfcAvailability
) {
model.setWaitingForNfc(true)
actions.readNfcInvitation { model.onInvitationResult(.nfc, $0) }
}
}
}
.padding(.horizontal, 20).padding(.vertical, 14)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
private struct MethodRow: View {
@Environment(\.vniColors) private var colors
let icon: String
var titleOverride: String? = nil
let titleKey: String
let descKey: String
let availability: ReceiveMethodAvailability
let onTap: () -> Void
var body: some View {
let enabled = availability == .available
Button(action: onTap) {
HStack(spacing: 14) {
Image(systemName: icon).font(.system(size: 22))
.foregroundStyle(enabled ? colors.brandLink : colors.foregroundLighter)
.frame(width: 24)
VStack(alignment: .leading, spacing: 3) {
if let titleOverride {
Text(titleOverride).font(VniType.bodyLarge)
} else {
Text(LocalizedStringKey(titleKey)).font(VniType.bodyLarge)
}
Text(LocalizedStringKey(descKey)).font(VniType.bodySmall).foregroundStyle(colors.foregroundLighter)
}
Spacer()
if availability == .unavailable {
Text(LocalizedStringKey("value_unavailable")).font(VniType.labelSmall).foregroundStyle(colors.foregroundLighter)
}
}
.padding(16)
.frame(maxWidth: .infinity)
.background(colors.backgroundSurface200, in: RoundedRectangle(cornerRadius: 14))
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.disabled(!enabled)
}
}
/// Invitation review panel, ported from `InvitationReviewPanel` in `ReceiveScreen.kt`.
struct InvitationReviewPanel: View {
@Environment(\.vniColors) private var colors
@ObservedObject var model: ReceiveModel
private var state: ReceiveState { model.state }
var body: some View {
VStack(alignment: .leading, spacing: 14) {
Text(LocalizedStringKey("receive_review_title")).font(VniType.titleLarge)
if state.isInspecting {
ProgressView().frame(maxWidth: .infinity).padding(40)
}
if let inspection = state.inspection {
let metadata = inspection.metadata
VStack(alignment: .leading, spacing: 8) {
Text(metadata.transferName).font(VniType.bodyLarge).lineLimit(2)
Text("\(metadata.fileCount) \(String(localized: "metadata_files").lowercased()) · \(formatBytes(metadata.totalSize))")
.foregroundStyle(colors.foregroundLighter)
}
.padding(16)
.frame(maxWidth: .infinity, alignment: .leading)
.background(colors.backgroundSurface200, in: RoundedRectangle(cornerRadius: 14))
Field(label: String(localized: "field_receiver_name"),
value: Binding(get: { state.receiverName }, set: model.setReceiverName))
Text(state.receiveFolder?.displayName ?? String(localized: "value_unavailable"))
.font(VniType.bodySmall)
.foregroundStyle(state.folderAccessStatus == .writable ? colors.foregroundLight : colors.destructiveDefault)
if state.isReceiving {
let progressId = state.activeReceiveTransferId
?? model.coreState.events.first { $0.direction == "receive" && $0.transferId != nil }?.transferId
let progress = progressId.flatMap { progressForTransfer(events: model.coreState.events, transferId: $0) }
ProgressRow(labelKey: progress?.labelKey ?? "progress_receiving", progress: progress?.progress, detail: progress?.detail)
SecondaryButton(title: String(localized: "button_cancel_receive"), action: model.cancelActiveReceive)
} else {
PrimaryButton(
title: String(localized: "button_receive"), action: model.receive,
enabled: state.canReceive(coreInitialized: model.coreState.isInitialized)
)
}
if let error = state.lastReceiveError {
Text(error.resolved()).font(VniType.bodySmall).foregroundStyle(colors.destructiveDefault)
}
}
}
.padding(.horizontal, 20).padding(.vertical, 14)
.frame(maxWidth: .infinity, alignment: .leading)
}
}

View File

@@ -0,0 +1,265 @@
import Foundation
import Combine
/// Which acquisition method produced an invitation, ported from `ReceiveMethod`.
enum ReceiveMethod {
case invitationFile
case qrCode
case nfc
}
enum ReceiveHistoryDeleteTarget: Equatable {
case transfer(transferId: UInt64)
case all
}
/// Receive feature state, ported from `feature/receive/ReceiveViewModel.kt`.
struct ReceiveState: Equatable {
var isAcquisitionOpen = false
var ticket = ""
var method: ReceiveMethod?
var inspection: TicketInspectionModel?
var receiverName = ""
var receiveFolder: ReceiveFolder?
var folderAccessStatus: FolderAccessStatus = .unavailable
var isInspecting = false
var isReceiving = false
var activeReceiveTransferId: UInt64?
var lastReceiveError: UiText?
var isWaitingForNfc = false
var historyDeleteTarget: ReceiveHistoryDeleteTarget?
var isDeletingHistory = false
func canReceive(coreInitialized: Bool) -> Bool {
coreInitialized && !ticket.isEmpty && inspection != nil
&& folderAccessStatus == .writable && !isReceiving && !isInspecting
}
static func == (lhs: ReceiveState, rhs: ReceiveState) -> Bool {
lhs.isAcquisitionOpen == rhs.isAcquisitionOpen && lhs.ticket == rhs.ticket
&& lhs.inspection == rhs.inspection && lhs.receiverName == rhs.receiverName
&& lhs.receiveFolder == rhs.receiveFolder && lhs.folderAccessStatus == rhs.folderAccessStatus
&& lhs.isInspecting == rhs.isInspecting && lhs.isReceiving == rhs.isReceiving
&& lhs.activeReceiveTransferId == rhs.activeReceiveTransferId
&& lhs.lastReceiveError == rhs.lastReceiveError && lhs.isWaitingForNfc == rhs.isWaitingForNfc
&& lhs.historyDeleteTarget == rhs.historyDeleteTarget && lhs.isDeletingHistory == rhs.isDeletingHistory
}
}
@MainActor
final class ReceiveModel: ObservableObject {
@Published private(set) var state = ReceiveState()
@Published private(set) var coreState = CoreState()
private let repository: CoreRepository
private let fileSystemService: FileSystemService
private let messages: UiMessageController
private var cancellables = Set<AnyCancellable>()
init(
repository: CoreRepository,
fileSystemService: FileSystemService,
preferences: AppPreferencesRepository,
messages: UiMessageController
) {
self.repository = repository
self.fileSystemService = fileSystemService
self.messages = messages
repository.$state.sink { [weak self] in self?.coreState = $0 }.store(in: &cancellables)
preferences.$preferences
.sink { [weak self] prefs in
guard let self else { return }
Task {
let folder = self.fileSystemService.effectiveReceiveFolder(prefs.receiveFolder)
let status = await self.fileSystemService.validateReceiveFolder(folder)
if self.state.receiverName.isEmpty { self.state.receiverName = prefs.username }
self.state.receiveFolder = folder
self.state.folderAccessStatus = status
}
}
.store(in: &cancellables)
repository.signals
.sink { [weak self] signal in
guard let self else { return }
if case .transfersChanged(let id) = signal {
Task { _ = await self.repository.refresh() }
if self.state.isReceiving && id != 0 {
self.state.activeReceiveTransferId = id
}
}
}
.store(in: &cancellables)
}
func openAcquisition() { state.isAcquisitionOpen = true }
func dismissAcquisition() {
if !state.isReceiving && !state.isInspecting { resetAcquisition() }
}
func setReceiverName(_ value: String) { state.receiverName = value }
func setWaitingForNfc(_ waiting: Bool) { state.isWaitingForNfc = waiting }
func requestDeleteHistoryItem(_ transferId: UInt64) {
let canDelete = coreState.transfers.contains {
$0.transferId == transferId && $0.direction == .receive && $0.status.isTerminalReceiveHistory
}
if canDelete { state.historyDeleteTarget = .transfer(transferId: transferId) }
}
func requestClearHistory() {
if coreState.transfers.contains(where: { $0.direction == .receive && $0.status.isTerminalReceiveHistory }) {
state.historyDeleteTarget = .all
}
}
func dismissHistoryDelete() {
if !state.isDeletingHistory { state.historyDeleteTarget = nil }
}
func confirmHistoryDelete() {
guard let target = state.historyDeleteTarget, !state.isDeletingHistory else { return }
state.isDeletingHistory = true
Task {
let result: Result<Void, Error>
switch target {
case .transfer(let id):
result = await repository.delete(transferId: id)
case .all:
result = (await repository.clearReceiveHistory()).map { _ in () }
}
switch result {
case .success:
state.historyDeleteTarget = nil
state.isDeletingHistory = false
let key = target == .all ? "receive_history_cleared" : "transfer_deleted"
messages.tryShow(UiMessage(text: .resource(key), tone: .success))
case .failure(let error):
state.isDeletingHistory = false
messages.error(error)
}
}
}
func onInvitationResult(_ method: ReceiveMethod, _ result: Result<String, Error>) {
state.isWaitingForNfc = false
switch result {
case .success(let raw): inspectInvitation(method, raw)
case .failure(let error): messages.error(error)
}
}
func receive() {
let current = state
guard let folder = current.receiveFolder else { return }
if !current.canReceive(coreInitialized: coreState.isInitialized) { return }
state.isReceiving = true
state.lastReceiveError = nil
state.activeReceiveTransferId = nil
Task {
let result: Result<Void, Error>
if folder.kind == .iosSecurityScopedUrl {
result = await repository.receiveIntoSecurityScopedDirectory(
ticket: current.ticket, outputDirectoryUrl: folder.value, receiverName: current.receiverName
)
} else {
result = await repository.receive(
ticket: current.ticket, outputDir: folder.value, receiverName: current.receiverName
)
}
switch result {
case .success:
resetAcquisition()
let canReveal = fileSystemService.canRevealReceiveFolder(folder)
messages.tryShow(UiMessage(
text: .resource("receive_completed"),
tone: .success,
actionLabel: canReveal ? .resource("button_show_in_files") : nil,
onAction: canReveal ? { [weak self] in self?.revealReceiveFolder(folder) } : nil
))
case .failure(let error):
if error.isUserCancellation {
state.isReceiving = false
state.activeReceiveTransferId = nil
state.lastReceiveError = nil
return
}
let uiText = error.toUiText()
state.isReceiving = false
state.activeReceiveTransferId = nil
state.lastReceiveError = uiText
messages.tryShow(UiMessage(
text: uiText,
tone: .error,
actionLabel: .resource("button_retry"),
onAction: { [weak self] in self?.receive() }
))
}
}
}
func cancelActiveReceive() {
let transferId = state.activeReceiveTransferId
?? coreState.transfers.first { $0.direction == .receive && $0.status == .receiving }?.transferId
?? coreState.events.first { $0.direction == "receive" && $0.transferId != nil }?.transferId
guard let transferId else { return }
Task {
let result = await repository.cancel(transferId: transferId)
switch result {
case .success:
state.isReceiving = false
state.activeReceiveTransferId = nil
state.lastReceiveError = nil
_ = await repository.refresh()
case .failure(let error):
messages.error(error)
}
}
}
private func revealReceiveFolder(_ folder: ReceiveFolder) {
Task {
let result = await fileSystemService.revealReceiveFolder(folder)
if case .failure = result {
messages.show(UiMessage(text: .resource("receive_open_files_failed"), tone: .error))
}
}
}
private func inspectInvitation(_ method: ReceiveMethod, _ raw: String) {
let ticket = raw.trimmingCharacters(in: .whitespacesAndNewlines)
if ticket.isEmpty { return messages.error(.resource("error_invitation_empty")) }
state.isAcquisitionOpen = true
state.ticket = ticket
state.method = method
state.inspection = nil
state.isInspecting = true
Task {
let result = await repository.inspectTicket(ticket)
switch result {
case .success(let inspection):
state.inspection = inspection
state.isInspecting = false
case .failure(let error):
state.ticket = ""
state.method = nil
state.inspection = nil
state.isInspecting = false
messages.error(error)
}
}
}
private func resetAcquisition() {
state.isAcquisitionOpen = false
state.ticket = ""
state.method = nil
state.inspection = nil
state.isInspecting = false
state.isReceiving = false
state.activeReceiveTransferId = nil
state.lastReceiveError = nil
state.isWaitingForNfc = false
}
}

View File

@@ -0,0 +1,149 @@
import SwiftUI
/// Receive screen, rebuilt on native SwiftUI. A grouped `List` of received
/// transfers with swipe-to-delete, and the acquisition flow as a native sheet.
struct ReceiveScreen: View {
@ObservedObject var model: ReceiveModel
let windowClass: WindowClass
private var transfers: [Transfer] {
model.coreState.transfers.filter { $0.direction == .receive }
}
private var deletable: [Transfer] {
transfers.filter { $0.status.isTerminalReceiveHistory }
}
var body: some View {
NavigationStack {
Group {
if transfers.isEmpty {
ScrollView { emptyState }
} else {
history
}
}
.navigationTitle(Text(LocalizedStringKey("receive_title")))
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button(action: model.openAcquisition) {
Label(String(localized: "button_receive_files"), systemImage: "plus")
}
}
if !deletable.isEmpty {
ToolbarItem(placement: .primaryAction) {
Button(role: .destructive, action: model.requestClearHistory) {
Label(String(localized: "receive_clear_history"), systemImage: "trash")
}
}
}
}
}
.adaptiveDrawer(
isPresented: Binding(get: { model.state.isAcquisitionOpen }, set: { _ in }),
windowClass: windowClass,
onDismiss: model.dismissAcquisition
) {
if model.state.ticket.isEmpty {
ReceiveMethodPanel(model: model)
} else {
InvitationReviewPanel(model: model)
}
}
.alert(
Text(LocalizedStringKey(clearAllPending ? "receive_clear_history_title" : "receive_delete_history_title")),
isPresented: Binding(get: { model.state.historyDeleteTarget != nil }, set: { if !$0 { Task { @MainActor in model.dismissHistoryDelete() } } })
) {
Button(String(localized: "button_cancel"), role: .cancel, action: model.dismissHistoryDelete)
Button(String(localized: clearAllPending ? "receive_clear_history" : "button_delete_transfer"),
role: .destructive, action: model.confirmHistoryDelete)
} message: {
historyDeleteMessage
}
}
private var history: some View {
List {
Section {
ForEach(transfers) { transfer in
ReceiveTransferRow(
transfer: transfer,
progress: progressForTransfer(events: model.coreState.events, transferId: transfer.transferId)
)
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
if transfer.status.isTerminalReceiveHistory {
Button(role: .destructive) {
model.requestDeleteHistoryItem(transfer.transferId)
} label: {
Label(String(localized: "button_delete_transfer"), systemImage: "trash")
}
}
}
}
} header: {
Text(LocalizedStringKey("receive_history_title"))
} footer: {
Text(LocalizedStringKey("receive_new_subtitle"))
}
}
}
private var emptyState: some View {
EmptyStateView(
systemImage: "tray.and.arrow.down",
title: String(localized: "receive_empty_title"),
message: String(localized: "receive_empty_body")
) {
Button(action: model.openAcquisition) {
Label(String(localized: "button_receive_files"), systemImage: "plus")
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
}
}
private var clearAllPending: Bool { model.state.historyDeleteTarget == .all }
@ViewBuilder
private var historyDeleteMessage: some View {
if let target = model.state.historyDeleteTarget {
if target == .all {
Text(LocalizedStringKey("receive_clear_history_description"))
} else {
Text(String(format: String(localized: "receive_delete_history_description"),
transferName(for: target) ?? String(localized: "receive_unknown_transfer")))
}
}
}
private func transferName(for target: ReceiveHistoryDeleteTarget) -> String? {
if case .transfer(let id) = target {
return transfers.first { $0.transferId == id }?.transferName
}
return nil
}
}
private struct ReceiveTransferRow: View {
let transfer: Transfer
let progress: TransferProgress?
var body: some View {
HStack(spacing: 12) {
Image(systemName: "doc")
.foregroundStyle(.secondary)
.frame(width: 40, height: 40)
.background(.quaternary, in: RoundedRectangle(cornerRadius: 9))
VStack(alignment: .leading, spacing: 3) {
Text(transfer.transferName ?? String(localized: "receive_unknown_transfer"))
.font(.body).lineLimit(1)
Text("\(formatBytes(transfer.totalSize)) · \(statusLabel(transfer.status))")
.font(.caption).foregroundStyle(.secondary)
if transfer.status == .receiving, let progress {
ProgressRow(labelKey: progress.labelKey, progress: progress.progress, detail: progress.detail)
.padding(.top, 2)
}
}
Spacer(minLength: 0)
}
}
}

View File

@@ -0,0 +1,142 @@
import Foundation
import Combine
/// Persisted per-transfer thumbnail store, ported from
/// `feature/send/FilePreviewRepository.kt` + `PlatformPreviewStore.ios.kt`.
/// Only small PNG/JPEG/WEBP previews are retained, under a total quota.
struct PreviewStoragePolicy {
var maxEntryBytes: Int = 512 * 1024
var maxTotalBytes: Int64 = 20 * 1024 * 1024
}
private struct PreviewFileInfo {
let transferId: UInt64
let byteSize: Int64
let modifiedAtMillis: Int64
}
@MainActor
final class FilePreviewRepository: ObservableObject {
@Published private(set) var previews: [UInt64: Data] = [:]
private let directory: String
private let policy: PreviewStoragePolicy
private let fm = FileManager.default
init(appDataDir: String, policy: PreviewStoragePolicy = PreviewStoragePolicy()) {
self.directory = (appDataDir as NSString).appendingPathComponent("ui/previews")
self.policy = policy
}
func restore(activeTransferIds: Set<UInt64>) {
let files = listFiles()
for file in files where !(activeTransferIds.contains(file.transferId)
&& (1...Int64(policy.maxEntryBytes)).contains(file.byteSize)) {
deleteFile(file.transferId)
}
enforceQuota()
var result: [UInt64: Data] = [:]
for file in listFiles() where activeTransferIds.contains(file.transferId) {
if let data = readFile(file.transferId), data.isSupportedPreview, data.count <= policy.maxEntryBytes {
result[file.transferId] = data
} else {
deleteFile(file.transferId)
}
}
previews = result
}
func save(transferId: UInt64, bytes: Data) {
guard (1...policy.maxEntryBytes).contains(bytes.count), bytes.isSupportedPreview else { return }
guard writeAtomically(transferId: transferId, bytes: bytes) else { return }
enforceQuota(protectedTransferId: transferId)
if readFile(transferId) != nil {
previews[transferId] = bytes
}
}
func remove(transferId: UInt64) {
deleteFile(transferId)
previews.removeValue(forKey: transferId)
}
// MARK: - Store (ported from IosPreviewStore)
private func enforceQuota(protectedTransferId: UInt64? = nil) {
let files = listFiles().sorted { $0.modifiedAtMillis < $1.modifiedAtMillis }
var total = files.reduce(Int64(0)) { $0 + $1.byteSize }
for file in files {
if total <= policy.maxTotalBytes { break }
if file.transferId == protectedTransferId { continue }
deleteFile(file.transferId)
total -= file.byteSize
previews.removeValue(forKey: file.transferId)
}
}
private func ensureDirectory() {
try? fm.createDirectory(atPath: directory, withIntermediateDirectories: true)
}
private func path(_ transferId: UInt64) -> String {
(directory as NSString).appendingPathComponent("\(transferId).preview")
}
private func listFiles() -> [PreviewFileInfo] {
ensureDirectory()
guard let names = try? fm.contentsOfDirectory(atPath: directory) else { return [] }
return names.compactMap { name in
guard name.hasSuffix(".preview"),
let id = UInt64(name.replacingOccurrences(of: ".preview", with: "")) else { return nil }
let full = (directory as NSString).appendingPathComponent(name)
guard let attrs = try? fm.attributesOfItem(atPath: full) else { return nil }
let size = (attrs[.size] as? NSNumber)?.int64Value ?? 0
let modified = ((attrs[.modificationDate] as? Date)?.timeIntervalSince1970 ?? 0) * 1000
return PreviewFileInfo(transferId: id, byteSize: size, modifiedAtMillis: Int64(modified))
}
}
private func readFile(_ transferId: UInt64) -> Data? {
try? Data(contentsOf: URL(fileURLWithPath: path(transferId)))
}
private func writeAtomically(transferId: UInt64, bytes: Data) -> Bool {
ensureDirectory()
if fm.fileExists(atPath: path(transferId)) { return true }
let temporary = (directory as NSString).appendingPathComponent(".\(transferId).tmp")
do {
try bytes.write(to: URL(fileURLWithPath: temporary), options: .atomic)
} catch {
return false
}
do {
if fm.fileExists(atPath: path(transferId)) {
try? fm.removeItem(atPath: temporary)
return true
}
try fm.moveItem(atPath: temporary, toPath: path(transferId))
return true
} catch {
try? fm.removeItem(atPath: temporary)
return false
}
}
private func deleteFile(_ transferId: UInt64) {
try? fm.removeItem(atPath: path(transferId))
}
}
extension Data {
/// Matches `isSupportedPreview()`: PNG / JPEG / WEBP magic bytes.
var isSupportedPreview: Bool {
let bytes = [UInt8](self)
let png = bytes.count >= 8 && bytes[0] == 0x89
&& bytes[1] == 0x50 && bytes[2] == 0x4E && bytes[3] == 0x47 // "PNG"
let jpeg = bytes.count >= 3 && bytes[0] == 0xFF && bytes[1] == 0xD8 && bytes[2] == 0xFF
let webp = bytes.count >= 12
&& bytes[0] == 0x52 && bytes[1] == 0x49 && bytes[2] == 0x46 && bytes[3] == 0x46 // "RIFF"
&& bytes[8] == 0x57 && bytes[9] == 0x45 && bytes[10] == 0x42 && bytes[11] == 0x50 // "WEBP"
return png || jpeg || webp
}
}

View File

@@ -0,0 +1,285 @@
import Foundation
import Combine
enum TransferDetailPanel: Equatable {
case activity
case receivers
case share
}
/// Which invitation delivery action produced a result (for success copy).
enum InvitationAction {
case export
case share
case nfc
}
/// Send feature state, ported from `feature/send/SendViewModel.kt` (`SendState`).
struct SendState: Equatable {
var isComposerOpen = false
var selectedFiles: [PickedShareFile] = []
var transferName = ""
var senderName = ""
var accessPolicy: ShareAccessPolicy = .requireApproval
var isSharing = false
var selectedTransferId: UInt64?
var transferThumbnails: [UInt64: Data] = [:]
var detailPanel: TransferDetailPanel?
var receiverHistory: [ReceiverRequestModel] = []
var isLoadingReceivers = false
var isDeleteConfirmationOpen = false
var isDeleting = false
func canCreateShare(coreInitialized: Bool) -> Bool {
coreInitialized && !selectedFiles.isEmpty && !transferName.isEmpty && !isSharing
}
}
@MainActor
final class SendModel: ObservableObject {
@Published private(set) var state = SendState()
@Published private(set) var coreState = CoreState()
/// Requests a file/folder pick or clipboard copy, consumed by the view layer.
@Published var pendingFilePick = false
@Published var pendingFolderPick = false
private let repository: CoreRepository
private let fileSystemService: FileSystemService
private let filePreviewRepository: FilePreviewRepository
private let messages: UiMessageController
private var cancellables = Set<AnyCancellable>()
init(
repository: CoreRepository,
fileSystemService: FileSystemService,
preferences: AppPreferencesRepository,
filePreviewRepository: FilePreviewRepository,
messages: UiMessageController
) {
self.repository = repository
self.fileSystemService = fileSystemService
self.filePreviewRepository = filePreviewRepository
self.messages = messages
repository.$state.sink { [weak self] in self?.coreState = $0 }.store(in: &cancellables)
repository.signals
.sink { [weak self] signal in
guard let self else { return }
switch signal {
case .transfersChanged:
Task { _ = await self.repository.refresh() }
case .receiverHistoryChanged(let id), .approvalChanged(let id):
if id == self.state.selectedTransferId { self.refreshReceivers(id) }
}
}
.store(in: &cancellables)
filePreviewRepository.$previews
.sink { [weak self] previews in self?.state.transferThumbnails = previews }
.store(in: &cancellables)
repository.$state
.map { core -> Set<UInt64>? in
core.isInitialized ? Set(core.transfers.map(\.transferId)) : nil
}
.removeDuplicates()
.sink { [weak self] ids in
if let ids { self?.filePreviewRepository.restore(activeTransferIds: ids) }
}
.store(in: &cancellables)
preferences.$preferences
.sink { [weak self] prefs in
guard let self else { return }
if self.state.senderName.isEmpty { self.state.senderName = prefs.username }
}
.store(in: &cancellables)
}
// MARK: - Composer
func openComposer() {
if state.isSharing { return }
let discarded = state.selectedFiles
state.isComposerOpen = true
state.selectedFiles = []
state.transferName = ""
state.accessPolicy = .requireApproval
discardPickedFiles(discarded)
}
func dismissComposer() {
if state.isSharing { return }
let discarded = state.selectedFiles
state.isComposerOpen = false
state.selectedFiles = []
state.transferName = ""
state.accessPolicy = .requireApproval
discardPickedFiles(discarded)
}
func selectFile() { pendingFilePick = true }
func selectFolder() { pendingFolderPick = true }
func onFilesPicked(_ files: [PickedShareFile]) {
if files.isEmpty { return }
let selectedValues = Set(files.map(\.value))
let discarded = state.selectedFiles.filter { !selectedValues.contains($0.value) }
state.isComposerOpen = true
state.selectedFiles = files
state.transferName = defaultTransferName(files)
discardPickedFiles(discarded)
}
func onFilePickFailed(_ reason: String) {
messages.error(InvitationError.message(reason.isEmpty ? "selection failed" : reason))
}
func clearSelectedSource() {
let discarded = state.selectedFiles
state.selectedFiles = []
state.transferName = ""
discardPickedFiles(discarded)
}
func removeSelectedFile(_ value: String) {
let discarded = state.selectedFiles.filter { $0.value == value }
let remaining = state.selectedFiles.filter { $0.value != value }
let wasDefault = state.transferName == defaultTransferName(state.selectedFiles)
state.selectedFiles = remaining
state.transferName = remaining.isEmpty ? "" : (wasDefault ? defaultTransferName(remaining) : state.transferName)
discardPickedFiles(discarded)
}
func setTransferName(_ value: String) { state.transferName = value }
func setSenderName(_ value: String) { state.senderName = value }
func setAccessPolicy(_ value: ShareAccessPolicy) { state.accessPolicy = value }
// MARK: - Transfer detail
func openTransfer(_ transferId: UInt64) {
state.selectedTransferId = transferId
state.detailPanel = nil
refreshReceivers(transferId)
}
func closeTransferDetails() {
state.selectedTransferId = nil
state.detailPanel = nil
state.receiverHistory = []
state.isDeleteConfirmationOpen = false
}
func openActivity() { state.detailPanel = .activity }
func openShare() { state.detailPanel = .share }
func openReceivers() {
guard let id = state.selectedTransferId else { return }
state.detailPanel = .receivers
refreshReceivers(id)
}
func closeDetailPanel() { state.detailPanel = nil }
func requestDeleteTransfer() { state.isDeleteConfirmationOpen = true }
func dismissDeleteTransfer() { if !state.isDeleting { state.isDeleteConfirmationOpen = false } }
func confirmDeleteTransfer() {
guard let transferId = state.selectedTransferId, !state.isDeleting else { return }
state.isDeleting = true
Task {
let result = await repository.delete(transferId: transferId)
switch result {
case .success:
filePreviewRepository.remove(transferId: transferId)
state.selectedTransferId = nil
state.detailPanel = nil
state.receiverHistory = []
state.isDeleteConfirmationOpen = false
state.isDeleting = false
messages.tryShow(UiMessage(text: .resource("transfer_deleted"), tone: .success))
case .failure(let error):
state.isDeleting = false
messages.error(error)
}
}
}
// MARK: - Invitation results / share
func onInvitationResult(_ action: InvitationAction, _ result: Result<Void, Error>) {
switch result {
case .success:
let key: String?
switch action {
case .export: key = "transfer_invitation_saved"
case .nfc: key = "transfer_nfc_written"
case .share: key = nil // system share sheet already confirms
}
if let key { messages.tryShow(UiMessage(text: .resource(key), tone: .success)) }
case .failure(let error):
messages.error(error)
}
}
func createShare() {
let current = state
if current.selectedFiles.isEmpty { return }
if !current.canCreateShare(coreInitialized: coreState.isInitialized) { return }
state.isSharing = true
Task {
let result = await fileSystemService.sharePickedFiles(
repository: repository,
files: current.selectedFiles,
transferName: current.transferName.trimmingCharacters(in: .whitespacesAndNewlines),
senderName: current.senderName.trimmingCharacters(in: .whitespacesAndNewlines),
accessPolicy: current.accessPolicy
)
switch result {
case .success(let share):
await fileSystemService.discardPickedFiles(current.selectedFiles)
if let thumb = current.selectedFiles.compactMap(\.thumbnailData).first {
filePreviewRepository.save(transferId: share.transferId, bytes: thumb)
}
state.isComposerOpen = false
state.selectedFiles = []
state.transferName = ""
state.accessPolicy = .requireApproval
state.isSharing = false
messages.show(UiMessage(text: .resource("send_transfer_created"), tone: .success))
case .failure(let error):
state.isSharing = false
messages.error(error)
}
}
}
// MARK: - Internals
private func defaultTransferName(_ files: [PickedShareFile]) -> String {
if files.isEmpty { return "" }
if files.count == 1 { return files[0].displayName }
if files.allSatisfy(\.isDirectory) { return "\(files.count) folders" }
return "\(files.count) files"
}
private func discardPickedFiles(_ files: [PickedShareFile]) {
if files.isEmpty { return }
Task { await fileSystemService.discardPickedFiles(files) }
}
private func refreshReceivers(_ transferId: UInt64) {
state.isLoadingReceivers = true
Task {
let result = await repository.receiverRequests(transferId: transferId)
switch result {
case .success(let requests):
state.receiverHistory = requests
state.isLoadingReceivers = false
case .failure(let error):
state.isLoadingReceivers = false
messages.error(error)
}
}
}
}

View File

@@ -0,0 +1,191 @@
import SwiftUI
/// Send screen, rebuilt on native SwiftUI. A grouped `List` of outgoing transfers,
/// with the composer and detail panels as native sheets and delete as an alert.
struct SendScreen: View {
@ObservedObject var model: SendModel
let windowClass: WindowClass
private var outgoing: [Transfer] {
model.coreState.transfers.filter { $0.direction == .send }
}
private var selectedTransfer: Transfer? {
guard let id = model.state.selectedTransferId else { return nil }
return outgoing.first { $0.transferId == id }
}
private var detailsBinding: Binding<Bool> {
Binding(get: { model.state.selectedTransferId != nil }, set: { if !$0 { model.closeTransferDetails() } })
}
var body: some View {
NavigationStack {
Group {
if outgoing.isEmpty {
ScrollView { emptyState }
} else {
catalog
}
}
.navigationTitle(Text(LocalizedStringKey("send_title")))
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button(action: model.openComposer) {
Label(String(localized: "button_create_new_transfer"), systemImage: "plus")
}
}
}
.navigationDestination(isPresented: detailsBinding) {
if let transfer = selectedTransfer {
detailView(for: transfer)
}
}
}
.adaptiveDrawer(
isPresented: Binding(get: { model.state.isComposerOpen }, set: { _ in }),
windowClass: windowClass,
onDismiss: model.dismissComposer
) {
TransferComposer(model: model, windowClass: windowClass)
}
}
/// The pushed transfer details view, with its detail-panel sheet and delete
/// alert attached here so they present from the detail's own context (presenting
/// modals from the parent stack while a detail is pushed is unreliable on macOS).
private func detailView(for transfer: Transfer) -> some View {
TransferDetailsView(model: model, transfer: transfer, events: model.coreState.events)
.adaptiveDrawer(
isPresented: Binding(get: { model.state.detailPanel != nil }, set: { _ in }),
windowClass: windowClass,
onDismiss: model.closeDetailPanel
) {
if let panel = model.state.detailPanel {
DetailPanelContent(model: model, transfer: transfer, panel: panel)
}
}
.alert(
Text(LocalizedStringKey("transfer_delete_title")),
isPresented: Binding(get: { model.state.isDeleteConfirmationOpen }, set: { if !$0 { Task { @MainActor in model.dismissDeleteTransfer() } } })
) {
Button(String(localized: "button_cancel"), role: .cancel, action: model.dismissDeleteTransfer)
Button(String(localized: "button_delete_transfer"), role: .destructive, action: model.confirmDeleteTransfer)
} message: {
Text(String(format: String(localized: "transfer_delete_description"),
transfer.transferName ?? String(localized: "send_new_transfer_title")))
}
}
private var catalog: some View {
List {
Section {
ForEach(outgoing) { transfer in
Button {
model.openTransfer(transfer.transferId)
} label: {
TransferListItem(
transfer: transfer,
thumbnail: model.state.transferThumbnails[transfer.transferId],
progress: progress(for: transfer)
)
}
.buttonStyle(.plain)
}
} header: {
Text(LocalizedStringKey("send_transfers_title"))
} footer: {
Text(LocalizedStringKey("send_subtitle"))
}
}
}
private var emptyState: some View {
EmptyStateView(
systemImage: "paperplane",
title: String(localized: "send_empty_title"),
message: String(localized: "send_empty_body")
) {
Button(action: model.openComposer) {
Label(String(localized: "button_create_new_transfer"), systemImage: "plus")
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
}
}
private func progress(for transfer: Transfer) -> TransferProgress? {
switch transfer.status {
case .importing: return progressForTransfer(events: model.coreState.events, transferId: transfer.transferId)
case .sharing: return activeSendProgress(events: model.coreState.events, transferId: transfer.transferId, totalSizeHint: transfer.totalSize)
default: return nil
}
}
}
private struct TransferListItem: View {
let transfer: Transfer
let thumbnail: Data?
let progress: TransferProgress?
var body: some View {
HStack(spacing: 12) {
FileArtwork(thumbnail: thumbnail)
.frame(width: 40, height: 40)
.background(.quaternary, in: RoundedRectangle(cornerRadius: 9))
VStack(alignment: .leading, spacing: 3) {
HStack {
Text(transfer.transferName ?? String(localized: "send_new_transfer_title"))
.font(.body).lineLimit(1)
Spacer()
StatusPill(label: statusLabel(transfer.status), tone: transfer.status.pillTone)
}
Text("\(formatBytes(transfer.totalSize)) · \(accessPolicyLabel(transfer.accessPolicy))")
.font(.caption).foregroundStyle(.secondary).lineLimit(1)
if let progress, transfer.status == .importing || transfer.status == .sharing {
ProgressRow(labelKey: progress.labelKey, progress: progress.progress, detail: progress.detail)
.padding(.top, 2)
}
}
Image(systemName: "chevron.forward")
.font(.footnote.weight(.semibold)).foregroundStyle(.tertiary)
}
.contentShape(Rectangle())
}
}
struct FileArtwork: View {
let thumbnail: Data?
var body: some View {
if let thumbnail, let image = PlatformImage.from(data: thumbnail) {
image.resizable().aspectRatio(contentMode: .fill)
.clipShape(RoundedRectangle(cornerRadius: 8))
} else {
Image(systemName: "doc")
.font(.system(size: 18))
.foregroundStyle(.secondary)
}
}
}
func statusLabel(_ status: TransferStatus) -> String {
String(localized: String.LocalizationValue(statusLabelKey(status)))
}
func accessPolicyLabel(_ policy: ShareAccessPolicy) -> String {
switch policy {
case .requireApproval: return String(localized: "send_access_approval")
case .anyoneWithTransfer: return String(localized: "send_access_anyone")
}
}
extension TransferStatus {
var pillTone: PillTone {
switch self {
case .sharing, .done: return .brand
case .importing, .receiving: return .warning
case .failed, .cancelled: return .destructive
case .stopped: return .neutral
}
}
}

View File

@@ -0,0 +1,169 @@
import SwiftUI
/// Transfer composer drawer, ported from `feature/send/TransferComposer.kt`.
/// Two steps: choose files/folder, then review + name + access policy + share.
struct TransferComposer: View {
@ObservedObject var model: SendModel
let windowClass: WindowClass
private var state: SendState { model.state }
var body: some View {
VStack(alignment: .leading, spacing: 16) {
if state.selectedFiles.isEmpty {
chooseStep
} else {
reviewStep
}
}
.padding(.horizontal, 20).padding(.vertical, 12)
.frame(maxWidth: .infinity, alignment: .leading)
.sendPickers(model: model)
}
private var chooseStep: some View {
VStack(alignment: .leading, spacing: 16) {
Text(LocalizedStringKey("send_choose_file_title")).font(.title2).fontWeight(.semibold)
Text(LocalizedStringKey("send_choose_file_body"))
.font(.subheadline).foregroundStyle(.secondary)
VStack(spacing: 14) {
Image(systemName: "doc").font(.system(size: 30)).foregroundStyle(.tint)
PrimaryButton(title: String(localized: "button_choose_files"), action: model.selectFile).fixedSize()
QuietButton(title: String(localized: "button_choose_folder"), action: model.selectFolder)
}
.frame(maxWidth: .infinity)
.padding(28)
.background(.quaternary.opacity(0.5), in: RoundedRectangle(cornerRadius: 16))
}
}
private var reviewStep: some View {
VStack(alignment: .leading, spacing: 16) {
Text(LocalizedStringKey("send_review_title")).font(.title2).fontWeight(.semibold)
if state.selectedFiles.count > 1 {
Text(String(format: String(localized: "send_selected_files_count"), state.selectedFiles.count))
.font(.subheadline).foregroundStyle(.secondary)
}
ForEach(state.selectedFiles) { file in
SelectedFileCard(
file: file,
canRemove: state.selectedFiles.count > 1 && !state.isSharing,
onRemove: { model.removeSelectedFile(file.value) }
)
}
Field(label: String(localized: "field_transfer_name"),
value: Binding(get: { state.transferName }, set: model.setTransferName))
Field(label: String(localized: "field_sender_name"),
value: Binding(get: { state.senderName }, set: model.setSenderName))
Text(LocalizedStringKey("send_access_title")).font(.headline)
PolicyOption(
icon: "checkmark.shield", titleKey: "send_access_approval", descKey: "send_access_approval_description",
selected: state.accessPolicy == .requireApproval,
onTap: { model.setAccessPolicy(.requireApproval) }
)
PolicyOption(
icon: "globe", titleKey: "send_access_anyone", descKey: "send_access_anyone_description",
selected: state.accessPolicy == .anyoneWithTransfer,
onTap: { model.setAccessPolicy(.anyoneWithTransfer) }
)
if state.accessPolicy == .anyoneWithTransfer {
Label(String(localized: "send_access_anyone_warning"), systemImage: "exclamationmark.triangle.fill")
.font(.caption).foregroundStyle(.orange)
}
actions
}
}
@ViewBuilder
private var actions: some View {
let shareTitle = state.isSharing
? String(localized: "button_sharing_file") : String(localized: "button_share_file")
let shareButton = PrimaryButton(
title: shareTitle, action: model.createShare,
enabled: state.canCreateShare(coreInitialized: model.coreState.isInitialized)
)
if windowClass == .phone {
VStack(spacing: 8) {
shareButton
QuietButton(title: String(localized: "button_change_files"), action: model.selectFile, enabled: !state.isSharing)
QuietButton(title: String(localized: "button_choose_folder"), action: model.selectFolder, enabled: !state.isSharing)
}
} else {
HStack(spacing: 8) {
shareButton.fixedSize()
QuietButton(title: String(localized: "button_change_files"), action: model.selectFile, enabled: !state.isSharing)
QuietButton(title: String(localized: "button_choose_folder"), action: model.selectFolder, enabled: !state.isSharing)
QuietButton(title: String(localized: "button_clear"), action: model.clearSelectedSource, enabled: !state.isSharing)
}
}
}
}
private struct SelectedFileCard: View {
let file: PickedShareFile
let canRemove: Bool
let onRemove: () -> Void
var body: some View {
HStack(spacing: 12) {
FileArtwork(thumbnail: file.thumbnailData)
.frame(width: 44, height: 44)
.background(.quaternary, in: RoundedRectangle(cornerRadius: 10))
VStack(alignment: .leading, spacing: 3) {
Text(file.displayName).lineLimit(1)
Text(subtitle).font(.caption).foregroundStyle(.secondary)
}
Spacer()
if canRemove {
Button(role: .destructive, action: onRemove) {
Image(systemName: "trash")
}
.buttonStyle(.borderless)
.tint(.red)
}
}
.padding(14)
.frame(maxWidth: .infinity)
.background(.quaternary.opacity(0.5), in: RoundedRectangle(cornerRadius: 14))
}
private var subtitle: String {
if file.isDirectory { return String(localized: "send_folder_label") }
if let size = file.sizeBytes { return formatBytes(size) }
return String(localized: "send_file_size_unknown")
}
}
private struct PolicyOption: View {
let icon: String
let titleKey: String
let descKey: String
let selected: Bool
let onTap: () -> Void
var body: some View {
Button(action: onTap) {
HStack(spacing: 12) {
Image(systemName: icon)
.font(.system(size: 20))
.foregroundStyle(selected ? AnyShapeStyle(.tint) : AnyShapeStyle(.secondary))
.frame(width: 22)
VStack(alignment: .leading, spacing: 3) {
Text(LocalizedStringKey(titleKey))
Text(LocalizedStringKey(descKey)).font(.caption).foregroundStyle(.secondary)
}
Spacer()
Image(systemName: selected ? "checkmark.circle.fill" : "circle")
.foregroundStyle(selected ? AnyShapeStyle(.tint) : AnyShapeStyle(.tertiary))
}
.padding(14)
.frame(maxWidth: .infinity)
.background(.quaternary.opacity(selected ? 0.8 : 0.4), in: RoundedRectangle(cornerRadius: 14))
.overlay(
RoundedRectangle(cornerRadius: 14)
.stroke(selected ? AnyShapeStyle(.tint) : AnyShapeStyle(.clear), lineWidth: 1.5)
)
}
.buttonStyle(.plain)
}
}

View File

@@ -0,0 +1,328 @@
import SwiftUI
import CoreImage.CIFilterBuiltins
/// Transfer details + drawer panels, ported from `feature/send/TransferDetails.kt`.
struct TransferDetailsView: View {
@ObservedObject var model: SendModel
let transfer: Transfer
let events: [CoreEventModel]
private var pendingReceivers: Int {
model.state.receiverHistory.filter { $0.status == .requested || $0.status == .accepted }.count
}
private var completedReceivers: Int {
model.state.receiverHistory.filter { $0.status == .completed }.count
}
var body: some View {
Form {
Section {
LabeledContent(String(localized: "metadata_status"), value: statusLabel(transfer.status))
LabeledContent(String(localized: "metadata_size"), value: formatBytes(transfer.totalSize))
LabeledContent(String(localized: "send_access_title"), value: accessPolicyLabel(transfer.accessPolicy))
} header: {
Text(transfer.transferName ?? String(localized: "send_new_transfer_title"))
}
Section {
DetailDestination(
title: String(localized: "transfer_activity_title"),
description: String(localized: "transfer_activity_description"),
count: events.filter { $0.transferId == transfer.transferId && $0.isMeaningfulActivity }.count,
onTap: model.openActivity
)
DetailDestination(
title: String(localized: "transfer_receivers_title"),
description: receiversDescription(pendingReceivers, completedReceivers),
count: pendingReceivers + completedReceivers,
onTap: model.openReceivers
)
DetailDestination(
title: String(localized: "transfer_share_title"),
description: String(localized: "transfer_share_description"),
count: 0,
onTap: model.openShare
)
}
}
.formStyle(.grouped)
.navigationTitle(Text(LocalizedStringKey("send_transfer_details_title")))
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button(role: .destructive, action: model.requestDeleteTransfer) {
Image(systemName: "trash")
}
}
}
}
}
private func receiversDescription(_ pending: Int, _ completed: Int) -> String {
if pending > 0 && completed > 0 {
return "\(String(format: String(localized: "transfer_receivers_pending"), pending)) · \(String(format: String(localized: "transfer_receivers_completed_count"), completed))"
}
if pending > 0 { return String(format: String(localized: "transfer_receivers_pending"), pending) }
if completed > 0 { return String(format: String(localized: "transfer_receivers_completed_count"), completed) }
return String(localized: "transfer_receivers_description")
}
private struct DetailDestination: View {
let title: String
let description: String
let count: Int
let onTap: () -> Void
var body: some View {
Button(action: onTap) {
HStack {
VStack(alignment: .leading, spacing: 3) {
Text(title).foregroundStyle(.primary)
Text(description).font(.caption).foregroundStyle(.secondary)
}
Spacer()
if count > 0 {
Text("\(count)")
.font(.footnote)
.foregroundStyle(.secondary)
}
Image(systemName: "chevron.forward")
.font(.footnote.weight(.semibold)).foregroundStyle(.tertiary)
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
}
// MARK: - Detail panels
struct DetailPanelContent: View {
@ObservedObject var model: SendModel
let transfer: Transfer
let panel: TransferDetailPanel
var body: some View {
switch panel {
case .activity:
TransferActivityPanel(events: model.coreState.events, transferId: transfer.transferId)
case .receivers:
ReceiverHistoryPanel(
receivers: model.state.receiverHistory,
loading: model.state.isLoadingReceivers,
events: model.coreState.events,
transferTotalSize: transfer.totalSize
)
case .share:
TransferSharePanel(model: model, transfer: transfer)
}
}
}
private struct PanelContainer<Content: View>: View {
let title: String
@ViewBuilder let content: () -> Content
var body: some View {
VStack(alignment: .leading, spacing: 14) {
Text(title).font(VniType.titleLarge)
content()
}
.padding(.horizontal, 20).padding(.vertical, 14)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
struct TransferActivityPanel: View {
@Environment(\.vniColors) private var colors
let events: [CoreEventModel]
let transferId: UInt64
var body: some View {
let visible = events
.filter { $0.transferId == transferId && $0.isMeaningfulActivity }
.sorted { $0.timestamp > $1.timestamp }
PanelContainer(title: String(localized: "transfer_activity_title")) {
if visible.isEmpty {
Text(LocalizedStringKey("transfer_no_activity")).foregroundStyle(colors.foregroundLighter)
} else {
ForEach(Array(visible.enumerated()), id: \.offset) { index, event in
if index > 0 { Divider().overlay(colors.borderDefault) }
Text(LocalizedStringKey(event.activityTitleKey))
.fontWeight(.medium).padding(.vertical, 14)
}
}
}
}
}
struct ReceiverHistoryPanel: View {
@Environment(\.vniColors) private var colors
let receivers: [ReceiverRequestModel]
let loading: Bool
let events: [CoreEventModel]
let transferTotalSize: UInt64
var body: some View {
PanelContainer(title: String(localized: "transfer_receivers_title")) {
if loading {
ProgressView().frame(maxWidth: .infinity).padding(40)
} else if receivers.isEmpty {
Text(LocalizedStringKey("transfer_no_receivers")).foregroundStyle(colors.foregroundLighter)
} else {
ForEach(Array(receivers.enumerated()), id: \.element.id) { index, receiver in
if index > 0 { Divider().overlay(colors.borderDefault) }
ReceiverRow(receiver: receiver, sendProgress: sendProgress(for: receiver))
}
}
}
}
private func sendProgress(for receiver: ReceiverRequestModel) -> TransferProgress? {
switch receiver.status {
case .accepted, .requested:
return progressForReceiver(events: events, transferId: receiver.transferId,
remoteEndpointId: receiver.remoteEndpointId, totalSizeHint: transferTotalSize)
default: return nil
}
}
}
private struct ReceiverRow: View {
@Environment(\.vniColors) private var colors
let receiver: ReceiverRequestModel
let sendProgress: TransferProgress?
var body: some View {
let name = receiver.receiverName ?? receiver.receiverDeviceName ?? String(localized: "transfer_nearby_device")
let showLive = sendProgress != nil && receiver.status != .completed
&& receiver.status != .refused && receiver.status != .expired
VStack(alignment: .leading, spacing: 6) {
Text(name).font(VniType.bodyLarge).lineLimit(1)
if let deviceName = receiver.receiverDeviceName, deviceName != name {
Text(deviceName).font(VniType.bodySmall).foregroundStyle(colors.foregroundLighter)
}
if showLive, let sendProgress {
ProgressRow(labelKey: sendProgress.labelKey, progress: sendProgress.progress, detail: sendProgress.detail)
} else {
Text(LocalizedStringKey(receiver.status.statusTextKey))
.font(VniType.bodySmall).fontWeight(.medium)
.foregroundStyle(receiver.status.statusColor(colors))
}
if let reason = receiver.reason, !reason.isEmpty {
Text(reason).font(VniType.bodySmall).foregroundStyle(colors.foregroundLighter)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.vertical, 13)
}
}
struct TransferSharePanel: View {
@Environment(\.vniColors) private var colors
@ObservedObject var model: SendModel
let transfer: Transfer
var body: some View {
PanelContainer(title: String(localized: "transfer_share_title")) {
if let ticket = transfer.ticket {
qrCard(ticket: ticket)
Text(LocalizedStringKey("transfer_scan_qr"))
.font(VniType.bodySmall).foregroundStyle(colors.foregroundLighter)
.frame(maxWidth: .infinity)
ShareActionsView(model: model, transfer: transfer, ticket: ticket)
} else {
Text(LocalizedStringKey("transfer_event_preparing")).foregroundStyle(colors.foregroundLighter)
}
}
}
private func qrCard(ticket: String) -> some View {
ZStack {
if let qr = QRCode.generate(from: ticket) {
qr.interpolation(.none).resizable().scaledToFit().padding(14)
} else {
ProgressView()
}
}
.frame(width: 268, height: 268)
.background(Color.white, in: RoundedRectangle(cornerRadius: 18))
.frame(maxWidth: .infinity)
}
}
// MARK: - QR generation (CoreImage)
enum QRCode {
static func generate(from string: String) -> Image? {
let context = CIContext()
let filter = CIFilter.qrCodeGenerator()
filter.message = Data(string.utf8)
filter.correctionLevel = "M"
guard let output = filter.outputImage else { return nil }
let scaled = output.transformed(by: CGAffineTransform(scaleX: 10, y: 10))
guard let cgImage = context.createCGImage(scaled, from: scaled.extent) else { return nil }
#if os(iOS)
return Image(uiImage: UIImage(cgImage: cgImage))
#else
return Image(nsImage: NSImage(cgImage: cgImage, size: .zero))
#endif
}
}
// MARK: - Event helpers (ported from TransferDetails.kt)
extension CoreEventModel {
var isMeaningfulActivity: Bool {
(phase == "import" && kind == "started")
|| (phase == "ticket" && kind == "created")
|| (phase == "network" && (kind == "connecting" || kind == "connected"))
|| (phase == "download" && kind == "found-collection")
|| (phase == "lifecycle" && ["done", "cancelled", "share-stopped"].contains(kind))
|| ["receiver-requested", "receiver-accepted", "receiver-auto-approved",
"receiver-refused", "receiver-completed", "share-stopped", "failed"].contains(kind)
}
var activityTitleKey: String {
if phase == "import" && kind == "started" { return "transfer_event_preparing" }
if phase == "ticket" && kind == "created" { return "transfer_event_ready" }
if phase == "network" { return "transfer_event_connecting" }
if phase == "download" { return "transfer_event_downloading" }
if phase == "export" { return "transfer_event_saving" }
if kind == "receiver-requested" { return "transfer_event_requested" }
if kind == "receiver-accepted" || kind == "receiver-auto-approved" { return "transfer_event_approved" }
if kind == "receiver-refused" { return "transfer_event_refused" }
if kind == "receiver-completed" { return "transfer_event_completed" }
if kind == "share-stopped" || (phase == "lifecycle" && kind == "cancelled") { return "transfer_event_stopped" }
if kind == "failed" { return "transfer_event_failed" }
return "transfer_event_updated"
}
}
extension ReceiverDeliveryStatus {
var statusTextKey: String {
switch self {
case .requested: return "transfer_receiver_requested"
case .accepted: return "transfer_receiver_accepted"
case .refused: return "transfer_receiver_refused"
case .expired: return "transfer_receiver_expired"
case .completed: return "transfer_receiver_completed"
case .unknown: return "transfer_receiver_unknown"
}
}
func statusColor(_ colors: VniDropColors) -> Color {
switch self {
case .completed: return colors.brandDefault
case .refused, .expired: return colors.destructiveDefault
default: return colors.foregroundLighter
}
}
}
#if os(iOS)
import UIKit
#else
import AppKit
#endif

View File

@@ -0,0 +1,61 @@
import SwiftUI
enum NfcShareAvailability { case available, unavailable, hidden }
/// Invitation delivery actions, ported from `TransferShareActions` (iosMain).
/// Platform implementations perform export, native share, and NFC write.
@MainActor
protocol TransferShareActions: AnyObject {
var canUseNativeShare: Bool { get }
var nfcAvailability: NfcShareAvailability { get }
func exportInvitation(ticket: String, transferName: String, onResult: @escaping (Result<Void, Error>) -> Void)
func shareInvitation(ticket: String, transferName: String, onResult: @escaping (Result<Void, Error>) -> Void)
func writeInvitationToNfc(ticket: String, onResult: @escaping (Result<Void, Error>) -> Void)
func cancelNfcWrite()
}
/// The QR + delivery buttons for a transfer's share panel, ported from the button
/// stack in `TransferSharePanel` (`TransferDetails.kt`).
struct ShareActionsView: View {
@Environment(\.vniColors) private var colors
@ObservedObject var model: SendModel
let transfer: Transfer
let ticket: String
@State private var actions: TransferShareActions = makePlatformShareActions()
@State private var writingNfc = false
var body: some View {
VStack(spacing: 12) {
if actions.nfcAvailability != .hidden {
SecondaryButton(
title: writingNfc ? String(localized: "transfer_nfc_waiting") : String(localized: "button_write_nfc"),
action: {
writingNfc = true
actions.writeInvitationToNfc(ticket: ticket) { result in
writingNfc = false
model.onInvitationResult(.nfc, result)
}
},
enabled: actions.nfcAvailability == .available && !writingNfc
)
if actions.nfcAvailability == .unavailable {
Text(LocalizedStringKey("transfer_nfc_unavailable"))
.font(VniType.bodySmall).foregroundStyle(colors.foregroundLighter)
}
}
SecondaryButton(title: String(localized: "button_download_invitation"), action: {
actions.exportInvitation(ticket: ticket, transferName: transfer.transferName ?? "") {
model.onInvitationResult(.export, $0)
}
})
PrimaryButton(title: String(localized: "button_native_share"), action: {
actions.shareInvitation(ticket: ticket, transferName: transfer.transferName ?? "") {
model.onInvitationResult(.share, $0)
}
}, enabled: actions.canUseNativeShare)
}
.onDisappear { actions.cancelNfcWrite() }
}
}

View File

@@ -0,0 +1,30 @@
import Foundation
/// Bug-report draft, ported from `diagnostics/BugReportService.kt`.
struct BugReportDraft {
let whatHappened: String
let expected: String
let steps: String
let contact: String
let includeLogs: Bool
}
/// Bug-report submission. The full diagnostics transport (URLSession + build
/// config) lands in the diagnostics phase; this protocol is the stable seam.
protocol BugReportService {
func submit(_ draft: BugReportDraft, deviceInfo: DeviceInfo?) async -> Result<Void, Error>
func previewLogBytes() async -> Int
}
/// Offline-safe no-op used until the diagnostics transport is configured.
struct NoopBugReportService: BugReportService {
func submit(_ draft: BugReportDraft, deviceInfo: DeviceInfo?) async -> Result<Void, Error> {
.failure(InvitationError.message("Bug reporting is not configured"))
}
func previewLogBytes() async -> Int { 0 }
}
/// Whether the diagnostics stack is compiled in (mirrors DiagnosticsBuildConfig).
enum DiagnosticsBuildConfig {
static let included = false
}

View File

@@ -0,0 +1,288 @@
import Foundation
import Combine
/// Settings sections, ported from `feature/settings/SettingsViewModel.kt`.
enum SettingsSection: Hashable {
case overview
case preferences
case appearance
case notifications
case about
case bugReport
var titleKey: String {
switch self {
case .overview: return "settings_title"
case .preferences: return "preferences_title"
case .appearance: return "appearance_title"
case .notifications: return "notifications_title"
case .about: return "about_title"
case .bugReport: return "about_bug_report"
}
}
}
struct SettingsState: Equatable {
var selectedSection: SettingsSection = .overview
var username = ""
var receiveFolder: ReceiveFolder?
var folderAccessStatus: FolderAccessStatus = .unavailable
var isValidatingFolder = false
var supportsCustomReceiveFolders = true
var themeMode: ThemeMode = .system
var notificationsEnabled = false
var notificationPermission: NotificationPermission = .notDetermined
var diagnosticsEnabled = false
var deviceInfo: DeviceInfo?
var appVersion = ""
var isLoadingDeviceInfo = false
var bugWhatHappened = ""
var bugExpected = ""
var bugSteps = ""
var bugContact = ""
var bugIncludeLogs = true
var isSubmittingBugReport = false
var bugLogPreviewBytes = 0
static func == (lhs: SettingsState, rhs: SettingsState) -> Bool {
lhs.selectedSection == rhs.selectedSection && lhs.username == rhs.username
&& lhs.receiveFolder == rhs.receiveFolder && lhs.folderAccessStatus == rhs.folderAccessStatus
&& lhs.isValidatingFolder == rhs.isValidatingFolder
&& lhs.supportsCustomReceiveFolders == rhs.supportsCustomReceiveFolders
&& lhs.themeMode == rhs.themeMode && lhs.notificationsEnabled == rhs.notificationsEnabled
&& lhs.notificationPermission == rhs.notificationPermission
&& lhs.diagnosticsEnabled == rhs.diagnosticsEnabled && lhs.appVersion == rhs.appVersion
&& lhs.isLoadingDeviceInfo == rhs.isLoadingDeviceInfo
&& lhs.bugWhatHappened == rhs.bugWhatHappened && lhs.bugExpected == rhs.bugExpected
&& lhs.bugSteps == rhs.bugSteps && lhs.bugContact == rhs.bugContact
&& lhs.bugIncludeLogs == rhs.bugIncludeLogs && lhs.isSubmittingBugReport == rhs.isSubmittingBugReport
&& lhs.bugLogPreviewBytes == rhs.bugLogPreviewBytes
&& lhs.deviceInfo?.operatingSystem == rhs.deviceInfo?.operatingSystem
}
}
@MainActor
final class SettingsModel: ObservableObject {
@Published private(set) var state: SettingsState
/// Set by the view to request the receive-folder picker (macOS).
@Published var pendingReceiveFolderPick = false
private let environment: PlatformEnvironment
private let deviceInfoProvider: DeviceInfoProvider
private let fileSystemService: FileSystemService
private let preferences: AppPreferencesRepository
private let notifications: LocalNotificationService
private let messages: UiMessageController
private let bugReports: BugReportService
private let diagnosticsIncluded: Bool
private var enableNotificationsAfterSettings = false
private var usernamePersistTask: Task<Void, Never>?
private var hasLocalUsernameDraft = false
private var cancellables = Set<AnyCancellable>()
init(
environment: PlatformEnvironment,
deviceInfoProvider: DeviceInfoProvider,
fileSystemService: FileSystemService,
preferences: AppPreferencesRepository,
notifications: LocalNotificationService,
messages: UiMessageController,
bugReports: BugReportService,
diagnosticsIncluded: Bool = DiagnosticsBuildConfig.included
) {
self.environment = environment
self.deviceInfoProvider = deviceInfoProvider
self.fileSystemService = fileSystemService
self.preferences = preferences
self.notifications = notifications
self.messages = messages
self.bugReports = bugReports
self.diagnosticsIncluded = diagnosticsIncluded
self.state = SettingsState(
supportsCustomReceiveFolders: fileSystemService.supportsCustomReceiveFolders,
appVersion: environment.appVersion
)
preferences.$preferences
.sink { [weak self] prefs in
guard let self else { return }
let previousFolder = self.state.receiveFolder
let folder = self.fileSystemService.effectiveReceiveFolder(prefs.receiveFolder)
self.state.username = self.hasLocalUsernameDraft ? self.state.username : prefs.username
self.state.receiveFolder = folder
self.state.themeMode = prefs.themeMode
self.state.notificationsEnabled = prefs.notificationsEnabled
self.state.diagnosticsEnabled = prefs.diagnosticsEnabled
if folder != previousFolder { Task { await self.validateFolder(folder) } }
}
.store(in: &cancellables)
refreshNotificationPermission()
loadDeviceInfo()
}
func selectSection(_ section: SettingsSection) {
state.selectedSection = section
if section == .about || section == .bugReport {
loadDeviceInfo()
if section == .bugReport { refreshBugLogPreview() }
}
}
func setUsername(_ value: String) {
hasLocalUsernameDraft = true
state.username = value
usernamePersistTask?.cancel()
usernamePersistTask = Task {
try? await Task.sleep(nanoseconds: 350_000_000)
if Task.isCancelled { return }
preferences.setUsername(value)
}
}
func setThemeMode(_ mode: ThemeMode) { preferences.setThemeMode(mode) }
func chooseReceiveFolder() {
if !fileSystemService.supportsCustomReceiveFolders { return }
pendingReceiveFolderPick = true
}
func onReceiveFolderPicked(_ folder: ReceiveFolder) { preferences.setReceiveFolder(folder) }
func onReceiveFolderPickFailed(_ reason: String) { messages.error(InvitationError.message(reason)) }
func resetReceiveFolder() { preferences.resetReceiveFolder() }
func setNotificationsEnabled(_ enabled: Bool) {
Task {
if !enabled {
preferences.setNotificationsEnabled(false)
notifications.cancelAll()
return
}
let permission = await notifications.requestPermission()
state.notificationPermission = permission
if permission == .granted {
await enableNotifications()
} else {
preferences.setNotificationsEnabled(false)
let key = permission == .unsupported ? "notifications_unsupported" : "notifications_permission_denied"
messages.show(UiMessage(
text: .resource(key),
tone: .warning,
actionLabel: permission == .denied ? .resource("button_open_settings") : nil,
onAction: permission == .denied ? { [weak self] in self?.openNotificationSettings() } : nil
))
}
}
}
func setDiagnosticsEnabled(_ enabled: Bool) {
if !diagnosticsIncluded { return }
Task {
preferences.setDiagnosticsEnabled(enabled)
messages.show(UiMessage(
text: .resource(enabled ? "diagnostics_enabled_message" : "diagnostics_disabled_message"),
tone: .success
))
}
}
func setBugWhatHappened(_ value: String) { state.bugWhatHappened = value }
func setBugExpected(_ value: String) { state.bugExpected = value }
func setBugSteps(_ value: String) { state.bugSteps = value }
func setBugContact(_ value: String) { state.bugContact = value }
func setBugIncludeLogs(_ value: Bool) { state.bugIncludeLogs = value }
func submitBugReport() {
if state.isSubmittingBugReport { return }
Task {
let snapshot = state
let what = snapshot.bugWhatHappened.trimmingCharacters(in: .whitespacesAndNewlines)
let expected = snapshot.bugExpected.trimmingCharacters(in: .whitespacesAndNewlines)
if what.isEmpty {
messages.show(UiMessage(text: .resource("bug_report_missing_what"), tone: .warning))
return
}
if expected.isEmpty {
messages.show(UiMessage(text: .resource("bug_report_missing_expected"), tone: .warning))
return
}
state.isSubmittingBugReport = true
let result = await bugReports.submit(
BugReportDraft(
whatHappened: what, expected: expected, steps: snapshot.bugSteps,
contact: snapshot.bugContact, includeLogs: snapshot.bugIncludeLogs
),
deviceInfo: snapshot.deviceInfo
)
switch result {
case .success:
state.isSubmittingBugReport = false
state.bugWhatHappened = ""
state.bugExpected = ""
state.bugSteps = ""
state.bugContact = ""
state.bugIncludeLogs = true
messages.show(UiMessage(text: .resource("bug_report_submitted"), tone: .success))
selectSection(.about)
case .failure:
state.isSubmittingBugReport = false
messages.show(UiMessage(text: .resource("bug_report_submit_failed"), tone: .error))
}
}
}
func openNotificationSettings() {
Task {
enableNotificationsAfterSettings = true
let result = await notifications.openSettings()
if case .failure = result {
enableNotificationsAfterSettings = false
messages.show(UiMessage(text: .resource("notifications_settings_open_failed"), tone: .error))
}
}
}
func refreshNotificationPermission() {
Task {
let permission = await notifications.refreshPermission()
state.notificationPermission = permission
if enableNotificationsAfterSettings {
enableNotificationsAfterSettings = false
if permission == .granted { await enableNotifications() }
} else if permission != .granted && state.notificationsEnabled {
preferences.setNotificationsEnabled(false)
notifications.cancelAll()
}
}
}
private func enableNotifications() async {
preferences.setNotificationsEnabled(true)
messages.show(UiMessage(text: .resource("notifications_enabled_message"), tone: .success))
}
private func loadDeviceInfo() {
if state.isLoadingDeviceInfo { return }
state.isLoadingDeviceInfo = true
Task {
let info = await deviceInfoProvider.load()
state.deviceInfo = info
state.isLoadingDeviceInfo = false
}
}
private func refreshBugLogPreview() {
Task {
let bytes = await bugReports.previewLogBytes()
state.bugLogPreviewBytes = bytes
}
}
private func validateFolder(_ folder: ReceiveFolder) async {
state.isValidatingFolder = true
let status = await fileSystemService.validateReceiveFolder(folder)
state.folderAccessStatus = status
state.isValidatingFolder = false
}
}

View File

@@ -0,0 +1,105 @@
import SwiftUI
/// Settings screen, rebuilt on a native `Form` with `NavigationStack` push
/// navigation. The model stays the source of truth via a derived path binding.
struct SettingsScreen: View {
@ObservedObject var model: SettingsModel
let windowClass: WindowClass
private var path: Binding<[SettingsSection]> {
Binding(
get: {
switch model.state.selectedSection {
case .overview: return []
case .bugReport: return [.about, .bugReport]
case let section: return [section]
}
},
set: { newPath in model.selectSection(newPath.last ?? .overview) }
)
}
var body: some View {
NavigationStack(path: path) {
Form {
Section {
NavigationLink(value: SettingsSection.preferences) {
SettingsRow(icon: "person.crop.circle", title: String(localized: "preferences_title"), value: model.state.username)
}
NavigationLink(value: SettingsSection.appearance) {
SettingsRow(icon: "sun.max", title: String(localized: "appearance_title"), value: themeModeLabel(model.state.themeMode))
}
}
Section {
NavigationLink(value: SettingsSection.notifications) {
SettingsRow(icon: "bell", title: String(localized: "notifications_title"), value: nil)
}
NavigationLink(value: SettingsSection.about) {
SettingsRow(icon: "info.circle", title: String(localized: "about_title"), value: nil)
}
}
}
.formStyle(.grouped)
.navigationTitle(Text(LocalizedStringKey("settings_title")))
.navigationDestination(for: SettingsSection.self) { section in
Form {
SettingsSectionContent(model: model, section: section)
}
.formStyle(.grouped)
.navigationTitle(Text(LocalizedStringKey(section.titleKey)))
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
}
}
}
}
private struct SettingsSectionContent: View {
@ObservedObject var model: SettingsModel
let section: SettingsSection
var body: some View {
switch section {
case .overview:
EmptyView()
case .preferences:
PreferencesSettings(model: model)
case .appearance:
AppearanceSettings(model: model)
case .notifications:
NotificationSettings(model: model)
case .about:
AboutSettings(model: model)
case .bugReport:
BugReportSettings(model: model)
}
}
}
struct SettingsRow: View {
let icon: String
let title: String
let value: String?
var body: some View {
HStack(spacing: 12) {
Image(systemName: icon)
.foregroundStyle(.tint)
.frame(width: 26)
Text(title).foregroundStyle(.primary)
Spacer()
if let value {
Text(value).foregroundStyle(.secondary).lineLimit(1)
}
}
}
}
func themeModeLabel(_ mode: ThemeMode) -> String {
switch mode {
case .system: return String(localized: "appearance_system_mode")
case .light: return String(localized: "appearance_light_mode")
case .dark: return String(localized: "appearance_dark_mode")
}
}

View File

@@ -0,0 +1,123 @@
import SwiftUI
/// Settings section detail views, rebuilt as native `Form` content. Each view is
/// placed inside a parent `Form`, so it returns `Section`s / rows directly.
struct PreferencesSettings: View {
@ObservedObject var model: SettingsModel
var body: some View {
Section(String(localized: "field_username")) {
TextField(String(localized: "field_username"),
text: Binding(get: { model.state.username }, set: model.setUsername))
}
if model.state.supportsCustomReceiveFolders {
Section(String(localized: "preferences_receive_folder_title")) {
Text(model.state.receiveFolder?.displayName ?? String(localized: "value_unavailable"))
.foregroundStyle(.secondary)
Button(String(localized: "button_choose_folder"), action: model.chooseReceiveFolder)
Button(String(localized: "button_reset_default"), action: model.resetReceiveFolder)
}
}
}
}
struct AppearanceSettings: View {
@ObservedObject var model: SettingsModel
var body: some View {
Section {
Picker(String(localized: "appearance_title"),
selection: Binding(get: { model.state.themeMode }, set: { model.setThemeMode($0) })) {
ForEach(ThemeMode.allCases, id: \.self) { mode in
Text(themeModeLabel(mode)).tag(mode)
}
}
.pickerStyle(.inline)
.labelsHidden()
}
}
}
struct NotificationSettings: View {
@ObservedObject var model: SettingsModel
var body: some View {
Section {
Toggle(isOn: Binding(
get: { model.state.notificationsEnabled },
set: { model.setNotificationsEnabled($0) }
)) {
Text(LocalizedStringKey("notifications_local_title"))
}
if model.state.notificationPermission == .denied {
Button(String(localized: "button_open_settings"), action: model.openNotificationSettings)
}
}
}
}
struct AboutSettings: View {
@ObservedObject var model: SettingsModel
var body: some View {
Section(String(localized: "about_title")) {
LabeledContent(String(localized: "version_title"), value: model.state.appVersion)
if let device = model.state.deviceInfo {
LabeledContent(String(localized: "device_model_title"), value: device.deviceModel ?? "")
LabeledContent(String(localized: "os_version_title"), value: device.operatingSystem)
}
}
if DiagnosticsBuildConfig.included {
Section {
Toggle(isOn: Binding(
get: { model.state.diagnosticsEnabled },
set: { model.setDiagnosticsEnabled($0) }
)) {
Text(LocalizedStringKey("diagnostics_title"))
}
}
}
Section {
NavigationLink(value: SettingsSection.bugReport) {
Text(LocalizedStringKey("about_bug_report"))
}
}
}
}
struct BugReportSettings: View {
@ObservedObject var model: SettingsModel
var body: some View {
Section(String(localized: "bug_report_what_label")) {
TextField(String(localized: "bug_report_what_label"),
text: Binding(get: { model.state.bugWhatHappened }, set: model.setBugWhatHappened), axis: .vertical)
.lineLimit(3, reservesSpace: true)
}
Section(String(localized: "bug_report_expected_label")) {
TextField(String(localized: "bug_report_expected_label"),
text: Binding(get: { model.state.bugExpected }, set: model.setBugExpected), axis: .vertical)
.lineLimit(3, reservesSpace: true)
}
Section(String(localized: "bug_report_steps_label")) {
TextField(String(localized: "bug_report_steps_label"),
text: Binding(get: { model.state.bugSteps }, set: model.setBugSteps), axis: .vertical)
.lineLimit(3, reservesSpace: true)
}
Section(String(localized: "bug_report_contact_label")) {
TextField(String(localized: "bug_report_contact_label"),
text: Binding(get: { model.state.bugContact }, set: model.setBugContact))
}
Section {
Toggle(isOn: Binding(get: { model.state.bugIncludeLogs }, set: { model.setBugIncludeLogs($0) })) {
Text(LocalizedStringKey("bug_report_include_logs"))
}
Button(action: model.submitBugReport) {
Text(model.state.isSubmittingBugReport
? String(localized: "bug_report_submitting") : String(localized: "bug_report_submit"))
}
.disabled(model.state.isSubmittingBugReport)
}
}
}

View File

@@ -0,0 +1,50 @@
#if os(iOS)
import Foundation
import UIKit
/// Builds the iOS dependency graph, ported from `rememberIosAppDependencies`.
@MainActor
func makeAppDependencies(externalInvitations: ExternalInvitationController) -> AppDependencies {
let device = UIDevice.current
let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0.1.0"
let env = PlatformEnvironment(
name: "\(device.systemName) \(device.systemVersion)",
appVersion: version,
defaultCoreDataDir: applicationDataDirectory(),
defaultUsername: device.name.isEmpty ? "Receiver" : device.name
)
return AppDependencies(
environment: env,
deviceInfoProvider: IosDeviceInfoProvider(),
fileSystemService: IosFileSystemService(),
notificationService: LocalNotificationService(),
externalInvitations: externalInvitations
)
}
private func applicationDataDirectory() -> String {
let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
return base.appendingPathComponent("VniDrop").path
}
private struct IosDeviceInfoProvider: DeviceInfoProvider {
@MainActor
func load() async -> DeviceInfo {
let device = UIDevice.current
let battery: String? = {
let wasMonitoring = device.isBatteryMonitoringEnabled
device.isBatteryMonitoringEnabled = true
defer { device.isBatteryMonitoringEnabled = wasMonitoring }
let level = device.batteryLevel
return level >= 0 ? "\(Int(level * 100))%" : nil
}()
return DeviceInfo(
deviceName: device.name,
deviceModel: device.model,
operatingSystem: "\(device.systemName) \(device.systemVersion)",
network: nil,
batteryLevel: battery
)
}
}
#endif

View File

@@ -0,0 +1,50 @@
#if os(macOS)
import Foundation
import AppKit
/// Builds the macOS dependency graph, mirroring `rememberIosAppDependencies`.
@MainActor
func makeAppDependencies(externalInvitations: ExternalInvitationController) -> AppDependencies {
let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0.1.0"
let host = Host.current().localizedName ?? "Mac"
let env = PlatformEnvironment(
name: "macOS " + ProcessInfo.processInfo.operatingSystemVersionString,
appVersion: version,
defaultCoreDataDir: applicationDataDirectory(),
defaultUsername: host
)
return AppDependencies(
environment: env,
deviceInfoProvider: MacDeviceInfoProvider(),
fileSystemService: MacFileSystemService(),
notificationService: LocalNotificationService(),
externalInvitations: externalInvitations
)
}
private func applicationDataDirectory() -> String {
let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
return base.appendingPathComponent("VniDrop").path
}
private struct MacDeviceInfoProvider: DeviceInfoProvider {
func load() async -> DeviceInfo {
DeviceInfo(
deviceName: Host.current().localizedName,
deviceModel: modelIdentifier(),
operatingSystem: "macOS " + ProcessInfo.processInfo.operatingSystemVersionString,
network: nil,
batteryLevel: nil
)
}
private func modelIdentifier() -> String? {
var size = 0
sysctlbyname("hw.model", nil, &size, nil, 0)
guard size > 0 else { return nil }
var model = [CChar](repeating: 0, count: size)
sysctlbyname("hw.model", &model, &size, nil, 0)
return String(cString: model)
}
}
#endif

View File

@@ -0,0 +1,98 @@
#if os(iOS)
import Foundation
import UIKit
import VnidropCore
/// iOS file system service, ported from `FileSystemService.ios.kt`.
/// App-owned Documents is the fixed receive folder; custom folders are not
/// supported because raw external picker URLs do not survive relaunch.
struct IosFileSystemService: FileSystemService {
var supportsCustomReceiveFolders: Bool { false }
func defaultReceiveFolder() -> ReceiveFolder {
let path = FileManager.default
.urls(for: .documentDirectory, in: .userDomainMask)
.first?.path ?? ""
return ReceiveFolder(kind: .fileSystemPath, value: path, displayName: "Documents")
}
func validateReceiveFolder(_ folder: ReceiveFolder) async -> FolderAccessStatus {
switch folder.kind {
case .fileSystemPath:
return FileManager.default.isWritableFile(atPath: folder.value) ? .writable : .unavailable
case .iosSecurityScopedUrl:
return validateSecurityScopedUrl(folder.value)
}
}
func canRevealReceiveFolder(_ folder: ReceiveFolder) -> Bool {
folder.kind == .fileSystemPath
&& folder.value.trimmingTrailingSlash == defaultReceiveFolder().value.trimmingTrailingSlash
}
func revealReceiveFolder(_ folder: ReceiveFolder) async -> Result<Void, Error> {
guard canRevealReceiveFolder(folder) else {
return .failure(InvitationError.message("The receive folder is not VniDrop Documents"))
}
guard let url = URL(string: "shareddocuments://\(folder.value)") else {
return .failure(InvitationError.message("The Files location URL is unavailable"))
}
let opened = await withCheckedContinuation { continuation in
DispatchQueue.main.async {
UIApplication.shared.open(url, options: [:]) { success in
continuation.resume(returning: success)
}
}
}
return opened ? .success(()) : .failure(InvitationError.message("Could not open VniDrop Documents in Files"))
}
func discardPickedFiles(_ files: [PickedShareFile]) async {
let paths = Set(files.filter { $0.isTemporaryCopy }.map { $0.value })
for path in paths {
try? FileManager.default.removeItem(atPath: path)
}
}
func sharePickedFiles(
repository: CoreRepository,
files: [PickedShareFile],
transferName: String,
senderName: String,
accessPolicy: ShareAccessPolicy
) async -> Result<Share, Error> {
guard !files.isEmpty else {
return .failure(InvitationError.message("Select at least one file to share"))
}
let sources = files.map { $0.toIosShareSource() }
return await repository.shareSources(
sources, transferName: transferName, senderName: senderName, accessPolicy: accessPolicy
)
}
private func validateSecurityScopedUrl(_ value: String) -> FolderAccessStatus {
let url = URL(string: value) ?? URL(fileURLWithPath: value)
let started = url.startAccessingSecurityScopedResource()
defer { if started { url.stopAccessingSecurityScopedResource() } }
if FileManager.default.isWritableFile(atPath: url.path) {
return .writable
}
return .permissionRequired
}
}
extension PickedShareFile {
/// iOS shares by filesystem path (from `asCopy` picker temp files).
func toIosShareSource() -> ShareSource {
ShareSource(kind: .path, value: value, displayName: displayName, isDirectory: isDirectory)
}
}
private extension String {
var trimmingTrailingSlash: String {
var s = self
while s.hasSuffix("/") { s.removeLast() }
return s
}
}
#endif

View File

@@ -0,0 +1,55 @@
#if os(macOS)
import Foundation
import AppKit
import VnidropCore
/// macOS file system service. Mirrors the desktop JVM behavior: default Downloads
/// receive folder, custom folders enabled via security-scoped bookmarks, reveal in
/// Finder. The Rust core streams bytes; Swift passes filesystem paths.
struct MacFileSystemService: FileSystemService {
var supportsCustomReceiveFolders: Bool { true }
func defaultReceiveFolder() -> ReceiveFolder {
let url = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first
?? FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Downloads")
return ReceiveFolder(kind: .fileSystemPath, value: url.path, displayName: url.lastPathComponent)
}
func validateReceiveFolder(_ folder: ReceiveFolder) async -> FolderAccessStatus {
FileManager.default.isWritableFile(atPath: folder.value) ? .writable : .unavailable
}
func canRevealReceiveFolder(_ folder: ReceiveFolder) -> Bool { true }
func revealReceiveFolder(_ folder: ReceiveFolder) async -> Result<Void, Error> {
let url = URL(fileURLWithPath: folder.value, isDirectory: true)
NSWorkspace.shared.activateFileViewerSelecting([url])
return .success(())
}
func discardPickedFiles(_ files: [PickedShareFile]) async {
let paths = Set(files.filter { $0.isTemporaryCopy }.map { $0.value })
for path in paths {
try? FileManager.default.removeItem(atPath: path)
}
}
func sharePickedFiles(
repository: CoreRepository,
files: [PickedShareFile],
transferName: String,
senderName: String,
accessPolicy: ShareAccessPolicy
) async -> Result<Share, Error> {
guard !files.isEmpty else {
return .failure(InvitationError.message("Select at least one file to share"))
}
let sources = files.map {
ShareSource(kind: .path, value: $0.value, displayName: $0.displayName, isDirectory: $0.isDirectory)
}
return await repository.shareSources(
sources, transferName: transferName, senderName: senderName, accessPolicy: accessPolicy
)
}
}
#endif

View File

@@ -0,0 +1,21 @@
import Foundation
/// Builds a `.vnd` invitation filename from a transfer name, mirroring the iOS
/// helper in `TransferShareActions.ios.kt`.
func invitationFileName(_ transferName: String) -> String {
let trimmed = transferName.trimmingCharacters(in: .whitespacesAndNewlines)
let base = trimmed.isEmpty ? "invitation" : trimmed
let safe = base.components(separatedBy: CharacterSet.alphanumerics.inverted.subtracting(CharacterSet(charactersIn: "-_ ")))
.joined()
.replacingOccurrences(of: " ", with: "-")
let name = safe.isEmpty ? "invitation" : safe
return "\(name).\(vniDropInvitationExtension)"
}
/// Writes a temporary `.vnd` file for sharing/exporting.
func writeTemporaryInvitation(ticket: String, transferName: String) throws -> URL {
let dir = FileManager.default.temporaryDirectory
let url = dir.appendingPathComponent(invitationFileName(transferName))
try ticket.write(to: url, atomically: true, encoding: .utf8)
return url
}

View File

@@ -0,0 +1,119 @@
import SwiftUI
import UniformTypeIdentifiers
/// Root-level pickers that are NOT triggered from inside a sheet. Currently just
/// the receive-folder picker (Settings is presented in the tab's navigation
/// stack, not a sheet, so presenting from the root works).
struct PlatformPickers: ViewModifier {
@ObservedObject var settingsModel: SettingsModel
func body(content: Content) -> some View {
content
.fileImporter(
isPresented: Binding(get: { settingsModel.pendingReceiveFolderPick }, set: { settingsModel.pendingReceiveFolderPick = $0 }),
allowedContentTypes: [.folder],
allowsMultipleSelection: false
) { result in
switch result {
case .success(let urls):
guard let url = urls.first else { return }
settingsModel.onReceiveFolderPicked(PickerSupport.receiveFolder(from: url))
case .failure(let error):
if !error.isUserCancellation { settingsModel.onReceiveFolderPickFailed(error.technicalDetail) }
}
}
}
}
/// Send file/folder pickers. Must be attached to the composer view so the picker
/// presents from the composer's sheet, not the already-presenting root controller.
struct SendPickers: ViewModifier {
@ObservedObject var model: SendModel
func body(content: Content) -> some View {
content
.fileImporter(
isPresented: Binding(get: { model.pendingFilePick }, set: { model.pendingFilePick = $0 }),
allowedContentTypes: [.item],
allowsMultipleSelection: true
) { result in
handleShareSelection(result, isDirectory: false)
}
.fileImporter(
isPresented: Binding(get: { model.pendingFolderPick }, set: { model.pendingFolderPick = $0 }),
allowedContentTypes: [.folder],
allowsMultipleSelection: false
) { result in
handleShareSelection(result, isDirectory: true)
}
}
private func handleShareSelection(_ result: Result<[URL], Error>, isDirectory: Bool) {
switch result {
case .success(let urls):
let files = urls.compactMap { PickerSupport.pickedFile(from: $0, isDirectory: isDirectory) }
if files.isEmpty {
model.onFilePickFailed("The selected document could not be opened")
} else {
model.onFilesPicked(files)
}
case .failure(let error):
if !error.isUserCancellation { model.onFilePickFailed(error.technicalDetail) }
}
}
}
enum PickerSupport {
static func receiveFolder(from url: URL) -> ReceiveFolder {
#if os(iOS)
// External receive folders on iOS use security-scoped URLs; the core holds
// access while streaming. Store the URL string.
return ReceiveFolder(kind: .iosSecurityScopedUrl, value: url.absoluteString, displayName: url.lastPathComponent)
#else
return ReceiveFolder(kind: .fileSystemPath, value: url.path, displayName: url.lastPathComponent)
#endif
}
static func pickedFile(from url: URL, isDirectory: Bool) -> PickedShareFile? {
let started = url.startAccessingSecurityScopedResource()
defer { if started { url.stopAccessingSecurityScopedResource() } }
#if os(iOS)
// Copy into a temporary sandbox location so the core can read the file
// after the picker/security scope ends. Folders are passed by path.
if isDirectory {
return PickedShareFile(value: url.path, displayName: url.lastPathComponent, isDirectory: true)
}
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent("share-\(UUID().uuidString)", isDirectory: true)
try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
let dest = tempDir.appendingPathComponent(url.lastPathComponent)
do {
try FileManager.default.copyItem(at: url, to: dest)
} catch {
return nil
}
let size = (try? dest.resourceValues(forKeys: [.fileSizeKey]))?.fileSize.map { UInt64($0) }
return PickedShareFile(
value: dest.path, displayName: url.lastPathComponent, sizeBytes: size,
isTemporaryCopy: true, isDirectory: false
)
#else
let size = isDirectory ? nil : (try? url.resourceValues(forKeys: [.fileSizeKey]))?.fileSize.map { UInt64($0) }
return PickedShareFile(
value: url.path, displayName: url.lastPathComponent, sizeBytes: size,
isTemporaryCopy: false, isDirectory: isDirectory
)
#endif
}
}
extension View {
func platformPickers(settingsModel: SettingsModel) -> some View {
modifier(PlatformPickers(settingsModel: settingsModel))
}
func sendPickers(model: SendModel) -> some View {
modifier(SendPickers(model: model))
}
}

View File

@@ -0,0 +1,262 @@
#if os(iOS)
import UIKit
import AVFoundation
import CoreNFC
import UniformTypeIdentifiers
@MainActor
func makeReceiveInvitationActions() -> ReceiveInvitationActions { IosReceiveInvitationActions() }
/// iOS invitation acquisition, ported from `ReceiveInvitationActions.ios.kt`:
/// document picker, camera QR scanner, and NFC read.
final class IosReceiveInvitationActions: NSObject, ReceiveInvitationActions, UIDocumentPickerDelegate {
private var documentResult: ((Result<String, Error>) -> Void)?
private var nfcReader: InvitationNfcReader?
private var qrController: QrScannerViewController?
var fileAvailability: ReceiveMethodAvailability { .available }
var qrAvailability: ReceiveMethodAvailability {
AVCaptureDevice.default(for: .video) != nil ? .available : .unavailable
}
var nfcAvailability: ReceiveMethodAvailability {
NFCNDEFReaderSession.readingAvailable ? .available : .unavailable
}
func pickInvitation(onResult: @escaping (Result<String, Error>) -> Void) {
cancel()
documentResult = onResult
let picker = UIDocumentPickerViewController(forOpeningContentTypes: [.data], asCopy: true)
picker.delegate = self
picker.modalPresentationStyle = .formSheet
guard let presenter = topPresenter() else {
return onResult(.failure(InvitationError.message("Could not find an iOS view controller")))
}
presenter.present(picker, animated: true)
}
func scanQrCode(onResult: @escaping (Result<String, Error>) -> Void) {
cancel()
guard let presenter = topPresenter() else {
return onResult(.failure(InvitationError.message("Could not find an iOS view controller")))
}
ensureCameraAccess { [weak self] granted in
guard let self else { return }
guard granted else {
return onResult(.failure(InvitationError.message("Camera access is required to scan QR codes")))
}
let scanner = QrScannerViewController { result in
self.qrController = nil
onResult(result)
}
self.qrController = scanner
scanner.modalPresentationStyle = .fullScreen
presenter.present(scanner, animated: true)
}
}
func readNfcInvitation(onResult: @escaping (Result<String, Error>) -> Void) {
cancel()
guard NFCNDEFReaderSession.readingAvailable else {
return onResult(.failure(InvitationError.message("NFC reading is unavailable on this device")))
}
let reader = InvitationNfcReader { [weak self] result in
self?.nfcReader = nil
onResult(result)
}
nfcReader = reader
reader.start()
}
func cancel() {
nfcReader?.cancel()
nfcReader = nil
qrController?.cancelScan()
qrController = nil
documentResult = nil
}
// UIDocumentPickerDelegate
func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
let result = documentResult
documentResult = nil
result?(Result {
guard let url = urls.first else { throw InvitationError.message("The selected invitation URL was invalid") }
let started = url.startAccessingSecurityScopedResource()
defer { if started { url.stopAccessingSecurityScopedResource() } }
let data = try Data(contentsOf: url)
guard data.count <= maxVniDropInvitationBytes else { throw InvitationError.tooLarge }
return try decodeInvitationBytes(data)
})
}
func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) {
documentResult = nil
}
private func ensureCameraAccess(_ completion: @escaping (Bool) -> Void) {
switch AVCaptureDevice.authorizationStatus(for: .video) {
case .authorized:
completion(true)
case .notDetermined:
AVCaptureDevice.requestAccess(for: .video) { granted in
DispatchQueue.main.async { completion(granted) }
}
default:
completion(false)
}
}
}
/// Full-screen camera QR scanner, ported from `QrScannerViewController`.
final class QrScannerViewController: UIViewController, AVCaptureMetadataOutputObjectsDelegate {
private let onResult: (Result<String, Error>) -> Void
private let session = AVCaptureSession()
private var previewLayer: AVCaptureVideoPreviewLayer?
private var finished = false
init(onResult: @escaping (Result<String, Error>) -> Void) {
self.onResult = onResult
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError() }
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .black
let hint = UILabel(frame: view.bounds)
hint.text = "Point the camera at a VniDrop QR code"
hint.textColor = .white
hint.textAlignment = .center
hint.numberOfLines = 0
hint.autoresizingMask = [.flexibleWidth, .flexibleHeight]
view.addSubview(hint)
let close = UIButton(type: .system)
close.setTitle("Cancel", for: .normal)
close.setTitleColor(.white, for: .normal)
close.frame = CGRect(x: 16, y: 52, width: 88, height: 36)
close.addAction(UIAction { [weak self] _ in self?.cancelScan() }, for: .touchUpInside)
view.addSubview(close)
configureSession()
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
previewLayer?.frame = view.bounds
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
if session.isRunning { session.stopRunning() }
}
func cancelScan() {
finish(.failure(InvitationError.message("QR scanning was cancelled")))
}
private func configureSession() {
guard let device = AVCaptureDevice.default(for: .video),
let input = try? AVCaptureDeviceInput(device: device),
session.canAddInput(input) else {
return finish(.failure(InvitationError.message("No camera is available")))
}
session.addInput(input)
let output = AVCaptureMetadataOutput()
guard session.canAddOutput(output) else {
return finish(.failure(InvitationError.message("Could not configure the QR scanner")))
}
session.addOutput(output)
output.setMetadataObjectsDelegate(self, queue: .main)
output.metadataObjectTypes = [.qr]
let layer = AVCaptureVideoPreviewLayer(session: session)
layer.videoGravity = .resizeAspectFill
layer.frame = view.bounds
view.layer.insertSublayer(layer, at: 0)
previewLayer = layer
session.sessionPreset = .high
DispatchQueue.global(qos: .userInitiated).async { [session] in session.startRunning() }
}
func metadataOutput(_ output: AVCaptureMetadataOutput, didOutput metadataObjects: [AVMetadataObject], from connection: AVCaptureConnection) {
let value = metadataObjects
.compactMap { $0 as? AVMetadataMachineReadableCodeObject }
.first { $0.type == .qr }?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if !value.isEmpty { finish(.success(value)) }
}
private func finish(_ result: Result<String, Error>) {
if finished { return }
finished = true
if session.isRunning { session.stopRunning() }
if presentingViewController != nil {
dismiss(animated: true) { self.onResult(result) }
} else {
onResult(result)
}
}
}
/// NFC invitation reader, ported from `InvitationNfcReader`.
final class InvitationNfcReader: NSObject, NFCNDEFReaderSessionDelegate {
private let onResult: (Result<String, Error>) -> Void
private var session: NFCNDEFReaderSession?
private var finished = false
init(onResult: @escaping (Result<String, Error>) -> Void) {
self.onResult = onResult
}
func start() {
let reader = NFCNDEFReaderSession(delegate: self, queue: .main, invalidateAfterFirstRead: true)
reader.alertMessage = "Hold your iPhone near a VniDrop invitation tag"
session = reader
reader.begin()
}
func cancel() {
session?.invalidate()
session = nil
}
func readerSession(_ session: NFCNDEFReaderSession, didInvalidateWithError error: Error) {
if finished { return }
let cancelled = (error as NSError).code == 200
finish(.failure(InvitationError.message(cancelled ? "NFC reading was cancelled" : error.localizedDescription)))
}
func readerSession(_ session: NFCNDEFReaderSession, didDetectNDEFs messages: [NFCNDEFMessage]) {
let result = Result<String, Error> {
let ticket = messages
.flatMap { $0.records }
.compactMap { payloadAsInvitation($0) }
.first
guard let ticket else { throw InvitationError.message("This NFC tag does not contain a VniDrop invitation") }
return ticket
}
session.invalidate()
finish(result)
}
private func finish(_ result: Result<String, Error>) {
if finished { return }
finished = true
session = nil
DispatchQueue.main.async { self.onResult(result) }
}
}
private func payloadAsInvitation(_ payload: NFCNDEFPayload) -> String? {
guard let type = String(data: payload.type, encoding: .utf8) else { return nil }
let data = payload.payload
if payload.typeNameFormat == .media && (type == vniDropInvitationMimeType || type.hasPrefix("text/")) {
return try? decodeInvitationBytes(data)
}
return nil
}
#endif

View File

@@ -0,0 +1,45 @@
#if os(macOS)
import AppKit
import UniformTypeIdentifiers
@MainActor
func makeReceiveInvitationActions() -> ReceiveInvitationActions { MacReceiveInvitationActions() }
/// macOS invitation acquisition: file picker only. QR (camera) and NFC are hidden
/// on the desktop, matching the availability model.
final class MacReceiveInvitationActions: ReceiveInvitationActions {
var fileAvailability: ReceiveMethodAvailability { .available }
var qrAvailability: ReceiveMethodAvailability { .hidden }
var nfcAvailability: ReceiveMethodAvailability { .hidden }
func pickInvitation(onResult: @escaping (Result<String, Error>) -> Void) {
let panel = NSOpenPanel()
panel.canChooseFiles = true
panel.canChooseDirectories = false
panel.allowsMultipleSelection = false
if let vnd = UTType(filenameExtension: vniDropInvitationExtension) {
panel.allowedContentTypes = [vnd, .data, .text]
}
panel.begin { response in
guard response == .OK, let url = panel.url else {
onResult(.failure(InvitationError.message("cancelled")))
return
}
onResult(Result {
let data = try Data(contentsOf: url)
return try decodeInvitationBytes(data)
})
}
}
func scanQrCode(onResult: @escaping (Result<String, Error>) -> Void) {
onResult(.failure(InvitationError.message("QR scanning is unavailable on macOS")))
}
func readNfcInvitation(onResult: @escaping (Result<String, Error>) -> Void) {
onResult(.failure(InvitationError.message("NFC is unavailable on macOS")))
}
func cancel() {}
}
#endif

View File

@@ -0,0 +1,154 @@
#if os(iOS)
import UIKit
import CoreNFC
@MainActor
func makePlatformShareActions() -> TransferShareActions { IosTransferShareActions() }
/// iOS invitation delivery, ported from `TransferShareActions.ios.kt`: export via
/// document picker, native share via `UIActivityViewController`, and NFC write.
final class IosTransferShareActions: NSObject, TransferShareActions {
private var nfcWriter: InvitationNfcWriter?
var canUseNativeShare: Bool { true }
var nfcAvailability: NfcShareAvailability {
NFCNDEFReaderSession.readingAvailable ? .available : .unavailable
}
func exportInvitation(ticket: String, transferName: String, onResult: @escaping (Result<Void, Error>) -> Void) {
onResult(Result {
let url = try writeTemporaryInvitation(ticket: ticket, transferName: transferName)
let picker = UIDocumentPickerViewController(forExporting: [url], asCopy: true)
picker.modalPresentationStyle = .formSheet
try present(picker)
})
}
func shareInvitation(ticket: String, transferName: String, onResult: @escaping (Result<Void, Error>) -> Void) {
onResult(Result {
let url = try writeTemporaryInvitation(ticket: ticket, transferName: transferName)
let controller = UIActivityViewController(activityItems: [url], applicationActivities: nil)
controller.modalPresentationStyle = .formSheet
try present(controller)
})
}
func writeInvitationToNfc(ticket: String, onResult: @escaping (Result<Void, Error>) -> Void) {
cancelNfcWrite()
guard NFCNDEFReaderSession.readingAvailable else {
onResult(.failure(InvitationError.message("NFC is unavailable on this device")))
return
}
let writer = InvitationNfcWriter(ticket: ticket) { [weak self] result in
self?.nfcWriter = nil
onResult(result)
}
nfcWriter = writer
writer.start()
}
func cancelNfcWrite() {
nfcWriter?.cancel()
nfcWriter = nil
}
@MainActor
private func present(_ controller: UIViewController) throws {
guard let presenter = topPresenter() else {
throw InvitationError.message("Could not find an iOS view controller")
}
presenter.present(controller, animated: true)
}
}
/// Writes a VniDrop invitation to a writable NDEF tag, ported from
/// `InvitationNfcWriter` in `TransferShareActions.ios.kt`.
final class InvitationNfcWriter: NSObject, NFCNDEFReaderSessionDelegate {
private let ticket: String
private let onResult: (Result<Void, Error>) -> Void
private var session: NFCNDEFReaderSession?
private var finished = false
init(ticket: String, onResult: @escaping (Result<Void, Error>) -> Void) {
self.ticket = ticket
self.onResult = onResult
}
func start() {
let reader = NFCNDEFReaderSession(delegate: self, queue: .main, invalidateAfterFirstRead: false)
reader.alertMessage = "Hold your iPhone near a writable NFC tag"
session = reader
reader.begin()
}
func cancel() {
session?.invalidate()
session = nil
}
func readerSession(_ session: NFCNDEFReaderSession, didInvalidateWithError error: Error) {
if finished { return }
let cancelled = (error as NSError).code == 200 // readerSessionInvalidationErrorUserCanceled
finish(.failure(InvitationError.message(cancelled ? "NFC writing was cancelled" : error.localizedDescription)))
}
func readerSession(_ session: NFCNDEFReaderSession, didDetectNDEFs messages: [NFCNDEFMessage]) {}
func readerSession(_ session: NFCNDEFReaderSession, didDetect tags: [NFCNDEFTag]) {
guard let tag = tags.first else {
return finish(.failure(InvitationError.message("No NFC tag was detected")))
}
session.connect(to: tag) { [weak self] connectError in
guard let self else { return }
if let connectError { return self.finish(.failure(connectError)) }
tag.queryNDEFStatus { status, _, queryError in
if let queryError { return self.finish(.failure(queryError)) }
switch status {
case .notSupported:
self.finish(.failure(InvitationError.message("This NFC tag does not support NDEF")))
case .readOnly:
self.finish(.failure(InvitationError.message("This NFC tag is read-only")))
default:
guard let message = self.invitationMessage() else {
return self.finish(.failure(InvitationError.message("Could not encode the invitation for NFC")))
}
tag.writeNDEF(message) { writeError in
if let writeError {
self.finish(.failure(writeError))
} else {
session.alertMessage = "Invitation written"
session.invalidate()
self.finish(.success(()))
}
}
}
}
}
}
private func invitationMessage() -> NFCNDEFMessage? {
guard let type = vniDropInvitationMimeType.data(using: .utf8),
let payload = ticket.data(using: .utf8) else { return nil }
let record = NFCNDEFPayload(format: .media, type: type, identifier: Data(), payload: payload)
return NFCNDEFMessage(records: [record])
}
private func finish(_ result: Result<Void, Error>) {
if finished { return }
finished = true
session = nil
DispatchQueue.main.async { self.onResult(result) }
}
}
@MainActor
func topPresenter() -> UIViewController? {
let scenes = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }
let keyWindow = scenes.flatMap { $0.windows }.first { $0.isKeyWindow }
var controller = keyWindow?.rootViewController
while let presented = controller?.presentedViewController {
controller = presented
}
return controller
}
#endif

View File

@@ -0,0 +1,48 @@
#if os(macOS)
import AppKit
import SwiftUI
@MainActor
func makePlatformShareActions() -> TransferShareActions { MacTransferShareActions() }
/// macOS invitation delivery, mirroring the iOS actions: save panel export and
/// `NSSharingServicePicker` native share. NFC is unavailable on macOS.
final class MacTransferShareActions: TransferShareActions {
var canUseNativeShare: Bool { true }
var nfcAvailability: NfcShareAvailability { .hidden }
func exportInvitation(ticket: String, transferName: String, onResult: @escaping (Result<Void, Error>) -> Void) {
let panel = NSSavePanel()
panel.nameFieldStringValue = invitationFileName(transferName)
panel.allowedContentTypes = []
panel.begin { response in
guard response == .OK, let url = panel.url else {
onResult(.failure(InvitationError.message("cancelled")))
return
}
onResult(Result { try ticket.write(to: url, atomically: true, encoding: .utf8) })
}
}
func shareInvitation(ticket: String, transferName: String, onResult: @escaping (Result<Void, Error>) -> Void) {
do {
let url = try writeTemporaryInvitation(ticket: ticket, transferName: transferName)
guard let view = NSApp.keyWindow?.contentView else {
onResult(.failure(InvitationError.message("No window available")))
return
}
let picker = NSSharingServicePicker(items: [url])
picker.show(relativeTo: .zero, of: view, preferredEdge: .minY)
onResult(.success(()))
} catch {
onResult(.failure(error))
}
}
func writeInvitationToNfc(ticket: String, onResult: @escaping (Result<Void, Error>) -> Void) {
onResult(.failure(InvitationError.message("NFC is unavailable on macOS")))
}
func cancelNfcWrite() {}
}
#endif

View File

@@ -0,0 +1,7 @@
{
"images" : [
{ "idiom" : "universal", "platform" : "ios", "size" : "1024x1024", "filename" : "app-icon.png" },
{ "idiom" : "mac", "scale" : "2x", "size" : "512x512", "filename" : "app-icon.png" }
],
"info" : { "author" : "xcode", "version" : 1 }
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

View File

@@ -0,0 +1 @@
{ "info" : { "author" : "xcode", "version" : 1 } }

View File

@@ -0,0 +1,85 @@
<?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>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>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>UILaunchScreen</key>
<dict/>
<key>CFBundleDocumentTypes</key>
<array>
<dict>
<key>CFBundleTypeName</key>
<string>VniDrop Invitation</string>
<key>CFBundleTypeRole</key>
<string>Viewer</string>
<key>LSHandlerRank</key>
<string>Owner</string>
<key>LSItemContentTypes</key>
<array>
<string>com.vnidrop.app.invitation</string>
</array>
</dict>
</array>
<key>UTExportedTypeDeclarations</key>
<array>
<dict>
<key>UTTypeConformsTo</key>
<array>
<string>public.data</string>
</array>
<key>UTTypeDescription</key>
<string>VniDrop Invitation</string>
<key>UTTypeIdentifier</key>
<string>com.vnidrop.app.invitation</string>
<key>UTTypeTagSpecification</key>
<dict>
<key>public.filename-extension</key>
<array>
<string>vnd</string>
</array>
<key>public.mime-type</key>
<string>application/vnd.vnidrop.transfer</string>
</dict>
</dict>
</array>
<key>LSSupportsOpeningDocumentsInPlace</key>
<true/>
<key>UIFileSharingEnabled</key>
<true/>
<key>UIBackgroundModes</key>
<array>
<string>fetch</string>
<string>processing</string>
<string>remote-notification</string>
</array>
<key>UIViewControllerBasedStatusBarAppearance</key>
<true/>
<key>NSCameraUsageDescription</key>
<string>VniDrop uses the camera to scan transfer QR codes.</string>
<key>NFCReaderUsageDescription</key>
<string>VniDrop uses NFC to read transfer invitation tags.</string>
<key>NSLocalNetworkUsageDescription</key>
<string>VniDrop needs local network access to send to other local devices if needed.</string>
<key>NSBonjourServices</key>
<array>
<string></string>
</array>
</dict>
</plist>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,24 @@
<?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>
<!-- iOS: NFC NDEF reading (matches the original iosApp entitlements). -->
<key>com.apple.developer.nfc.readersession.formats</key>
<array>
<string>NDEF</string>
</array>
<!-- macOS App Sandbox: user-selected files for share/receive, and network
client/server for the local P2P transfer. These keys are ignored on iOS. -->
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.files.user-selected.read-write</key>
<true/>
<key>com.apple.security.files.downloads.read-write</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>
<key>com.apple.security.network.server</key>
<true/>
</dict>
</plist>

View File

@@ -0,0 +1,66 @@
import SwiftUI
/// Presents modal content in a native sheet. On phones it uses medium/large
/// detents with a grabber; on wider layouts the sheet is form-sized. Content is
/// wrapped in a `NavigationStack` so it gets a native title bar + Close button.
struct AdaptiveDrawer<DrawerContent: View>: ViewModifier {
@Binding var isPresented: Bool
let windowClass: WindowClass
let onDismiss: () -> Void
@ViewBuilder let drawerContent: () -> DrawerContent
func body(content: Content) -> some View {
content.sheet(
isPresented: Binding(get: { isPresented }, set: { if !$0 { onDismiss() } })
) {
SheetChrome(onClose: onDismiss) { drawerContent() }
.modifier(PhoneDetents(enabled: windowClass == .phone))
}
}
}
private struct PhoneDetents: ViewModifier {
let enabled: Bool
func body(content: Content) -> some View {
if enabled {
content
.presentationDetents([.medium, .large])
.presentationDragIndicator(.visible)
} else {
content.frame(minWidth: 460, minHeight: 480)
}
}
}
private struct SheetChrome<Content: View>: View {
let onClose: () -> Void
@ViewBuilder let content: () -> Content
var body: some View {
NavigationStack {
ScrollView { content().padding(.top, 4) }
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button(String(localized: "button_close"), action: onClose)
}
}
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
}
}
}
extension View {
func adaptiveDrawer<DrawerContent: View>(
isPresented: Binding<Bool>,
windowClass: WindowClass,
onDismiss: @escaping () -> Void,
@ViewBuilder content: @escaping () -> DrawerContent
) -> some View {
modifier(AdaptiveDrawer(
isPresented: isPresented, windowClass: windowClass,
onDismiss: onDismiss, drawerContent: content
))
}
}

View File

@@ -0,0 +1,50 @@
import SwiftUI
/// Native SwiftUI button styles. Purple accent comes from the app-wide `.tint`.
/// Full-width filled button (`.borderedProminent`). Apply `.fixedSize()` at the
/// call site to shrink it to its content.
struct PrimaryButton: View {
let title: String
let action: () -> Void
var enabled: Bool = true
var body: some View {
Button(action: action) {
Text(title).frame(maxWidth: .infinity).frame(minHeight: 22)
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
.disabled(!enabled)
}
}
/// Full-width bordered (secondary) button.
struct SecondaryButton: View {
let title: String
let action: () -> Void
var enabled: Bool = true
var body: some View {
Button(action: action) {
Text(title).frame(maxWidth: .infinity).frame(minHeight: 22)
}
.buttonStyle(.bordered)
.controlSize(.large)
.disabled(!enabled)
}
}
/// Borderless tinted text button.
struct QuietButton: View {
let title: String
let action: () -> Void
var enabled: Bool = true
var body: some View {
Button(title, action: action)
.buttonStyle(.borderless)
.disabled(!enabled)
}
}

View File

@@ -0,0 +1,112 @@
import SwiftUI
// MARK: - StatusPill
enum PillTone { case neutral, success, warning, destructive, brand }
struct StatusPill: View {
let label: String
var tone: PillTone = .neutral
private var color: Color {
switch tone {
case .neutral: return .secondary
case .success, .brand: return VniDropColors.brandPurple
case .warning: return .orange
case .destructive: return .red
}
}
var body: some View {
HStack(spacing: 5) {
Circle().fill(color).frame(width: 6, height: 6)
Text(label).font(.caption).fontWeight(.medium).foregroundStyle(color).lineLimit(1)
}
.padding(.horizontal, 9).padding(.vertical, 4)
.background(color.opacity(0.14), in: Capsule())
}
}
// MARK: - ProgressRow
struct ProgressRow: View {
let labelKey: String
let progress: Double?
var detail: String? = nil
var body: some View {
VStack(alignment: .leading, spacing: 4) {
HStack {
Text(LocalizedStringKey(labelKey)).font(.subheadline).lineLimit(1)
Spacer()
if let progress {
Text("\(Int(progress * 100))%").font(.caption).foregroundStyle(.secondary)
}
}
if let detail {
Text(detail).font(.caption).foregroundStyle(.secondary).lineLimit(1)
}
if let progress {
ProgressView(value: progress)
} else {
ProgressView().progressViewStyle(.linear)
}
}
.frame(maxWidth: .infinity)
}
}
// MARK: - Field
/// Labeled text field using native styling. Renders cleanly both inside a `Form`
/// row and standalone (e.g. inside a sheet).
struct Field: View {
let label: String
@Binding var value: String
var minLines: Int = 1
var enabled: Bool = true
var body: some View {
VStack(alignment: .leading, spacing: 6) {
Text(label).font(.subheadline).foregroundStyle(.secondary)
Group {
if minLines > 1 {
TextField(label, text: $value, axis: .vertical)
.lineLimit(minLines, reservesSpace: true)
} else {
TextField(label, text: $value)
}
}
.textFieldStyle(.roundedBorder)
.disabled(!enabled)
}
}
}
// MARK: - EmptyStateView
/// Native-styled empty state (iOS 16 compatible; avoids iOS 17
/// `ContentUnavailableView`).
struct EmptyStateView<Actions: View>: View {
let systemImage: String
let title: String
let message: String
@ViewBuilder var actions: () -> Actions
var body: some View {
VStack(spacing: 12) {
Image(systemName: systemImage)
.font(.system(size: 52))
.foregroundStyle(.secondary)
Text(title).font(.title2).fontWeight(.semibold).multilineTextAlignment(.center)
Text(message)
.font(.body).foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.frame(maxWidth: 420)
actions().padding(.top, 4)
}
.frame(maxWidth: .infinity)
.padding(.horizontal, 24)
.padding(.vertical, 48)
}
}

View File

@@ -0,0 +1,20 @@
import SwiftUI
/// Cross-platform decoding of raw image bytes into a SwiftUI `Image`.
enum PlatformImage {
static func from(data: Data) -> Image? {
#if os(iOS)
guard let ui = UIImage(data: data) else { return nil }
return Image(uiImage: ui)
#else
guard let ns = NSImage(data: data) else { return nil }
return Image(nsImage: ns)
#endif
}
}
#if os(iOS)
import UIKit
#else
import AppKit
#endif

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
}

View File

@@ -0,0 +1,27 @@
import Foundation
/// Top-level destinations, ported from `ui/navigation/AppDestination.kt`.
enum AppDestination: String, CaseIterable, Identifiable {
case send
case receive
case settings
var id: String { rawValue }
var labelKey: String {
switch self {
case .send: return "nav_send"
case .receive: return "nav_receive"
case .settings: return "nav_settings"
}
}
/// SF Symbol approximating the Compose line icon.
var systemImage: String {
switch self {
case .send: return "paperplane"
case .receive: return "tray.and.arrow.down"
case .settings: return "gearshape"
}
}
}

View File

@@ -0,0 +1,11 @@
import SwiftUI
/// Semantic type scale mapped from the Material typography styles the Compose UI
/// uses, so screens reference the same names during the port.
enum VniType {
static let titleLarge = Font.system(size: 22, weight: .semibold)
static let bodyLarge = Font.system(size: 16)
static let bodyMedium = Font.system(size: 14)
static let bodySmall = Font.system(size: 12)
static let labelSmall = Font.system(size: 11, weight: .medium)
}

View File

@@ -0,0 +1,189 @@
import SwiftUI
/// Direct Compose port of the VniDrop semantic color tokens
/// (`shared/.../ui/theme/VniDropTheme.kt`). The app uses these semantic tokens
/// directly because a single SwiftUI/Material color scheme cannot represent the
/// full surface, border, and foreground stack.
struct VniDropColors {
let backgroundDefault: Color
let backgroundDashCanvas: Color
let backgroundDashSidebar: Color
let backgroundSurface75: Color
let backgroundSurface100: Color
let backgroundSurface200: Color
let backgroundSurface300: Color
let backgroundSurface400: Color
let backgroundMuted: Color
let backgroundControl: Color
let backgroundSelection: Color
let backgroundButton: Color
let backgroundOverlayHover: Color
let backgroundDialog: Color
let borderDefault: Color
let borderStrong: Color
let borderStronger: Color
let borderMuted: Color
let borderControl: Color
let foregroundDefault: Color
let foregroundLight: Color
let foregroundLighter: Color
let foregroundMuted: Color
let foregroundContrast: Color
let brandLink: Color
let brandButton: Color
let brandDefault: Color
let brand600: Color
let brand500: Color
let brand400: Color
let brand300: Color
let brand200: Color
let warningDefault: Color
let warning200: Color
let warning300: Color
let warning400: Color
let warning500: Color
let warning600: Color
let destructiveDefault: Color
let destructive200: Color
let destructive300: Color
let destructive400: Color
let destructive500: Color
let destructive600: Color
}
extension VniDropColors {
/// The single brand accent used app-wide as the SwiftUI tint.
static let brandPurple = Color.hsl(271, 91, 65)
static let light = VniDropColors(
backgroundDefault: .hsl(0, 0, 98.8),
backgroundDashCanvas: .hsl(0, 0, 97.3),
backgroundDashSidebar: .hsl(0, 0, 98.8),
backgroundSurface75: .hsl(0, 0, 100),
backgroundSurface100: .hsl(0, 0, 98.8),
backgroundSurface200: .hsl(0, 0, 95.3),
backgroundSurface300: .hsl(0, 0, 92.9),
backgroundSurface400: .hsl(0, 0, 89.8),
backgroundMuted: .hsl(0, 0, 96.9),
backgroundControl: .hsl(0, 0, 95.3),
backgroundSelection: .hsl(0, 0, 92.9),
backgroundButton: .hsl(0, 0, 91),
backgroundOverlayHover: .hsl(0, 0, 95.3),
backgroundDialog: .hsl(0, 0, 100),
borderDefault: .hsl(0, 0, 87.5),
borderStrong: .hsl(0, 0, 83.1),
borderStronger: .hsl(0, 0, 56.1),
borderMuted: .hsl(0, 0, 92.9),
borderControl: .hsl(0, 0, 78),
foregroundDefault: .hsl(0, 0, 9),
foregroundLight: .hsl(0, 0, 32.2),
foregroundLighter: .hsl(0, 0, 43.9),
foregroundMuted: .hsl(0, 0, 69.8),
foregroundContrast: .hsl(0, 0, 98.4),
brandLink: .hsl(271, 91, 65),
brandButton: .hsl(270, 95, 75),
brandDefault: .hsl(271, 91, 65),
brand600: .hsl(271, 81, 56),
brand500: .hsl(271, 91, 65),
brand400: .hsl(270, 95, 75),
brand300: .hsl(269, 97, 85),
brand200: .hsl(269, 100, 92),
warningDefault: .hsl(38.9, 100, 57.1),
warning200: .hsl(40, 81.8, 97.8),
warning300: .hsl(44.3, 100, 91.8),
warning400: .hsl(41.9, 100, 81.8),
warning500: .hsl(36.3, 85.7, 67.1),
warning600: .hsl(30.3, 80.3, 47.8),
destructiveDefault: .hsl(10.2, 77.9, 53.9),
destructive200: .hsl(0, 100, 99.4),
destructive300: .hsl(7.1, 100, 96.7),
destructive400: .hsl(7.1, 91.3, 91),
destructive500: .hsl(10.4, 77.1, 79.4),
destructive600: .hsl(9.9, 82, 43.5)
)
static let dark = VniDropColors(
backgroundDefault: .hsl(0, 0, 7.1),
backgroundDashCanvas: .hsl(0, 0, 7.1),
backgroundDashSidebar: .hsl(0, 0, 9),
backgroundSurface75: .hsl(0, 0, 9),
backgroundSurface100: .hsl(0, 0, 12.2),
backgroundSurface200: .hsl(0, 0, 12.9),
backgroundSurface300: .hsl(0, 0, 16.1),
backgroundSurface400: .hsl(0, 0, 16.1),
backgroundMuted: .hsl(0, 0, 14.1),
backgroundControl: .hsl(0, 0, 14.1),
backgroundSelection: .hsl(0, 0, 19.2),
backgroundButton: .hsl(0, 0, 18),
backgroundOverlayHover: .hsl(0, 0, 18),
backgroundDialog: .hsl(0, 0, 7.1),
borderDefault: .hsl(0, 0, 18),
borderStrong: .hsl(0, 0, 21.2),
borderStronger: .hsl(0, 0, 27.1),
borderMuted: .hsl(0, 0, 14.1),
borderControl: .hsl(0, 0, 22.4),
foregroundDefault: .hsl(0, 0, 98),
foregroundLight: .hsl(0, 0, 70.6),
foregroundLighter: .hsl(0, 0, 53.7),
foregroundMuted: .hsl(0, 0, 30.2),
foregroundContrast: .hsl(0, 0, 8.6),
brandLink: .hsl(270, 95, 75),
brandButton: .hsl(271, 81, 56),
brandDefault: .hsl(270, 95, 75),
brand600: .hsl(271, 91, 65),
brand500: .hsl(271, 81, 56),
brand400: .hsl(273, 67, 39),
brand300: .hsl(274, 66, 32),
brand200: .hsl(274, 87, 21),
warningDefault: .hsl(38.9, 100, 42.9),
warning200: .hsl(36.6, 100, 8),
warning300: .hsl(32.3, 100, 10.2),
warning400: .hsl(33.2, 100, 14.5),
warning500: .hsl(34.8, 90.9, 21.6),
warning600: .hsl(38.9, 100, 42.9),
destructiveDefault: .hsl(10.2, 77.9, 53.9),
destructive200: .hsl(10.9, 23.4, 9.2),
destructive300: .hsl(7.5, 51.3, 15.3),
destructive400: .hsl(6.7, 60, 20.6),
destructive500: .hsl(7.9, 71.6, 29),
destructive600: .hsl(9.7, 85.2, 62.9)
)
}
extension Color {
/// HSL constructor matching the Compose `hsl()` helper (hue in degrees,
/// saturation and lightness in percent).
static func hsl(_ hue: Double, _ saturation: Double, _ lightness: Double) -> Color {
let h = (hue.truncatingRemainder(dividingBy: 360) + 360)
.truncatingRemainder(dividingBy: 360) / 360
let s = min(max(saturation, 0), 100) / 100
let l = min(max(lightness, 0), 100) / 100
if s == 0 {
return Color(red: l, green: l, blue: l)
}
let q = l < 0.5 ? l * (1 + s) : l + s - l * s
let p = 2 * l - q
return Color(
red: hueToRgb(p, q, h + 1.0 / 3.0),
green: hueToRgb(p, q, h),
blue: hueToRgb(p, q, h - 1.0 / 3.0)
)
}
}
private func hueToRgb(_ p: Double, _ q: Double, _ input: Double) -> Double {
var t = input
if t < 0 { t += 1 }
if t > 1 { t -= 1 }
let value: Double
if t < 1.0 / 6.0 {
value = p + (q - p) * 6 * t
} else if t < 1.0 / 2.0 {
value = q
} else if t < 2.0 / 3.0 {
value = p + (q - p) * (2.0 / 3.0 - t) * 6
} else {
value = p
}
return min(1, max(0, value))
}

View File

@@ -0,0 +1,56 @@
import SwiftUI
/// User-facing theme selection, mirrors `ThemeMode` in the Compose theme.
enum ThemeMode: String, CaseIterable, Codable, Sendable {
case system
case light
case dark
/// SwiftUI color-scheme override (`nil` follows the system).
var preferredColorScheme: ColorScheme? {
switch self {
case .system: return nil
case .light: return .light
case .dark: return .dark
}
}
}
func resolveDarkTheme(_ mode: ThemeMode, systemDark: Bool) -> Bool {
switch mode {
case .system: return systemDark
case .light: return false
case .dark: return true
}
}
private struct VniDropColorsKey: EnvironmentKey {
static let defaultValue = VniDropColors.light
}
extension EnvironmentValues {
/// Semantic VniDrop tokens for the active theme. Read with
/// `@Environment(\.vniColors) private var colors`.
var vniColors: VniDropColors {
get { self[VniDropColorsKey.self] }
set { self[VniDropColorsKey.self] = newValue }
}
}
/// Provides the semantic token set matching the resolved light/dark theme to the
/// whole subtree. Apply once near the app root.
struct VniDropTheme: ViewModifier {
let isDark: Bool
func body(content: Content) -> some View {
content
.environment(\.vniColors, isDark ? .dark : .light)
.tint(VniDropColors.brandPurple)
}
}
extension View {
func vniDropTheme(isDark: Bool) -> some View {
modifier(VniDropTheme(isDark: isDark))
}
}

View File

@@ -0,0 +1,30 @@
// swift-tools-version:5.9
import PackageDescription
// Local package wrapping the VniDrop Rust core:
// - `vnidrop` binary target: the xcframework of static libraries + the FFI
// C module (`vnidropFFI`), produced by apple/scripts/build-core.sh.
// - `VnidropCore` target: the generated Swift bindings (`Vnidrop.swift`).
//
// Both `vnidrop.xcframework` and `Sources/VnidropCore/Vnidrop.swift` are build
// outputs and are gitignored; run apple/scripts/build-core.sh to (re)generate.
let package = Package(
name: "VnidropCore",
platforms: [
.iOS(.v16),
.macOS(.v13),
],
products: [
.library(name: "VnidropCore", targets: ["VnidropCore"]),
],
targets: [
.binaryTarget(
name: "vnidrop",
path: "vnidrop.xcframework"
),
.target(
name: "VnidropCore",
dependencies: ["vnidrop"]
),
]
)

View File

@@ -0,0 +1,25 @@
# VnidropCore (Swift package)
Swift bindings for the VniDrop Rust transfer core (`crates/vnidrop`), generated
with UniFFI in library mode. The Rust crate is never modified for this — the
Swift surface is produced from the compiled staticlib.
## Regenerate
From the repository root:
```bash
apple/scripts/build-core.sh release # or: debug
```
This builds `libvnidrop.a` for `aarch64-apple-ios`, `aarch64-apple-ios-sim`, and
`aarch64-apple-darwin`, generates `Sources/VnidropCore/Vnidrop.swift`, and
assembles `vnidrop.xcframework`.
## Generated / ignored artifacts
- `vnidrop.xcframework/`
- `Sources/VnidropCore/Vnidrop.swift`
Both are gitignored. A clean checkout must run the build script before opening
the Xcode project.

46
apple/project.yml Normal file
View File

@@ -0,0 +1,46 @@
# XcodeGen spec for the native SwiftUI VniDrop app (iOS/iPadOS/macOS).
# Regenerate the project with: xcodegen generate (run from apple/)
# Requires the Rust core first: apple/scripts/build-core.sh debug
name: VniDrop
options:
bundleIdPrefix: com.vnidrop
deploymentTarget:
iOS: "18.2"
macOS: "15.0"
createIntermediateGroups: true
packages:
VnidropCore:
path: VnidropCore
targets:
VniDrop:
type: application
supportedDestinations: [iOS, macOS]
sources:
- path: VniDrop
excludes:
- "Resources/Info.plist"
- "Resources/VniDrop.entitlements"
- "Resources/**/.DS_Store"
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: com.vnidrop.app
MARKETING_VERSION: "0.1.0"
CURRENT_PROJECT_VERSION: "1"
GENERATE_INFOPLIST_FILE: NO
INFOPLIST_FILE: VniDrop/Resources/Info.plist
SWIFT_VERSION: "5.9"
ENABLE_USER_SCRIPT_SANDBOXING: NO
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
CODE_SIGN_STYLE: Automatic
configs:
debug:
CODE_SIGN_ENTITLEMENTS: VniDrop/Resources/VniDrop.entitlements
release:
CODE_SIGN_ENTITLEMENTS: VniDrop/Resources/VniDrop.entitlements
dependencies:
- package: VnidropCore
- sdk: SystemConfiguration.framework
- sdk: Security.framework
- sdk: libresolv.tbd

104
apple/scripts/build-core.sh Executable file
View File

@@ -0,0 +1,104 @@
#!/usr/bin/env bash
#
# Builds the VniDrop Rust core for Apple platforms and produces:
# - apple/VnidropCore/vnidrop.xcframework (static libs for device/sim/macOS)
# - apple/VnidropCore/Sources/VnidropCore/Vnidrop.swift (generated bindings)
#
# The Rust crate (crates/vnidrop) is NOT modified. Bindings are generated in
# UniFFI library mode from the compiled staticlib, so the Swift surface always
# matches the scaffolding baked into the library.
#
# Usage: apple/scripts/build-core.sh [debug|release] (default: release)
set -euo pipefail
# Default to debug: the workspace `[profile.release]` uses thin LTO, which the
# current macOS toolchain miscompiles into corrupt host proc-macro dylibs
# ("mis-aligned LINKEDIT string pool"), breaking any release cross-compile. Debug
# static libs are correct and adequate for development and the simulator. For a
# release build, pass `release` AND disable LTO for proc-macros/build scripts via
# CARGO_PROFILE_RELEASE_BUILD_OVERRIDE_LTO=false in a Cargo.toml profile — see the
# package README. The Rust crate itself is never modified.
PROFILE="${1:-debug}"
case "$PROFILE" in
debug) CARGO_PROFILE_FLAG="" ;;
release) CARGO_PROFILE_FLAG="--release" ;;
*) echo "profile must be debug or release" >&2; exit 1 ;;
esac
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
APPLE_DIR="$REPO_ROOT/apple"
PKG_DIR="$APPLE_DIR/VnidropCore"
GEN_DIR="$PKG_DIR/Sources/VnidropCore"
TARGET_DIR="$REPO_ROOT/target"
BUILD_DIR="$APPLE_DIR/.build-core"
# Match the iOS deployment target used by the Gobley config in shared/build.gradle.kts.
export IPHONEOS_DEPLOYMENT_TARGET="${IPHONEOS_DEPLOYMENT_TARGET:-16.0}"
export MACOSX_DEPLOYMENT_TARGET="${MACOSX_DEPLOYMENT_TARGET:-13.0}"
# The workspace `[profile.dev] strip = "debuginfo"` corrupts host proc-macro
# dylibs on the current Apple toolchain ("mis-aligned LINKEDIT string pool"),
# which breaks compilation. The Gobley Xcode run-script uses the same override.
# This never touches the Rust crate — it only changes how the build is invoked.
export CARGO_PROFILE_DEV_STRIP=none
IOS_TARGET="aarch64-apple-ios"
SIM_ARM_TARGET="aarch64-apple-ios-sim"
SIM_X64_TARGET="x86_64-apple-ios"
MAC_TARGET="aarch64-apple-darwin"
echo "==> Building vnidrop staticlib ($PROFILE) for Apple targets"
for t in "$IOS_TARGET" "$SIM_ARM_TARGET" "$SIM_X64_TARGET" "$MAC_TARGET"; do
echo " - $t"
rustup target add "$t" >/dev/null 2>&1 || true
( cd "$REPO_ROOT" && cargo build -p vnidrop --target "$t" $CARGO_PROFILE_FLAG )
done
LIB_SUBDIR="$PROFILE"
[ "$PROFILE" = "debug" ] && LIB_SUBDIR="debug"
MAC_LIB="$TARGET_DIR/$MAC_TARGET/$LIB_SUBDIR/libvnidrop.a"
IOS_LIB="$TARGET_DIR/$IOS_TARGET/$LIB_SUBDIR/libvnidrop.a"
# Fresh scratch dir for the bindgen output and the universal simulator lib.
rm -rf "$BUILD_DIR"
mkdir -p "$BUILD_DIR"
# Combine the two simulator architectures into one universal static library so
# the xcframework works on both Apple Silicon and Intel simulators.
SIM_LIB="$BUILD_DIR/libvnidrop-sim.a"
lipo -create \
"$TARGET_DIR/$SIM_ARM_TARGET/$LIB_SUBDIR/libvnidrop.a" \
"$TARGET_DIR/$SIM_X64_TARGET/$LIB_SUBDIR/libvnidrop.a" \
-output "$SIM_LIB"
echo "==> Generating Swift bindings (library mode)"
( cd "$REPO_ROOT" && cargo run -p uniffi-bindgen -- generate \
--library "$MAC_LIB" \
--language swift \
--out-dir "$BUILD_DIR" )
# UniFFI emits: Vnidrop.swift, vnidropFFI.h, vnidropFFI.modulemap
mkdir -p "$GEN_DIR"
cp "$BUILD_DIR/Vnidrop.swift" "$GEN_DIR/Vnidrop.swift"
# Assemble a headers dir the xcframework can carry as the FFI module.
HEADERS_DIR="$BUILD_DIR/headers"
mkdir -p "$HEADERS_DIR"
cp "$BUILD_DIR/vnidropFFI.h" "$HEADERS_DIR/"
# The xcframework module map must be named module.modulemap.
cp "$BUILD_DIR/vnidropFFI.modulemap" "$HEADERS_DIR/module.modulemap"
echo "==> Assembling xcframework"
XCFRAMEWORK="$PKG_DIR/vnidrop.xcframework"
rm -rf "$XCFRAMEWORK"
xcodebuild -create-xcframework \
-library "$IOS_LIB" -headers "$HEADERS_DIR" \
-library "$SIM_LIB" -headers "$HEADERS_DIR" \
-library "$MAC_LIB" -headers "$HEADERS_DIR" \
-output "$XCFRAMEWORK"
echo "==> Done."
echo " xcframework: $XCFRAMEWORK"
echo " bindings: $GEN_DIR/Vnidrop.swift"

View File

@@ -0,0 +1,16 @@
[package]
name = "uniffi-bindgen"
version = "0.1.0"
edition = "2021"
license = "Apache-2.0"
publish = false
# Standalone UniFFI bindings generator used to produce the Swift bindings for the
# Apple app. It intentionally pins the same UniFFI version as `crates/vnidrop` so
# generated bindings never drift from the scaffolding compiled into the library.
[[bin]]
name = "uniffi-bindgen"
path = "src/main.rs"
[dependencies]
uniffi = { version = "=0.29.4", features = ["cli"] }

View File

@@ -0,0 +1,3 @@
fn main() {
uniffi::uniffi_bindgen_main()
}