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

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

View File

@@ -0,0 +1,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])
}