mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-05 02:29:55 +02:00
feat(apple): native SwiftUI app for iOS and macOS
Add a native SwiftUI VniDrop app (Send/Receive/Settings) talking to the Rust core via generated UniFFI Swift bindings, plus the uniffi-bindgen helper crate. iOS uses a TabView, macOS a NavigationSplitView sidebar.
This commit is contained in:
54
Cargo.lock
generated
54
Cargo.lock
generated
@@ -61,6 +61,12 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anstyle"
|
||||||
|
version = "1.0.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "anyhow"
|
name = "anyhow"
|
||||||
version = "1.0.103"
|
version = "1.0.103"
|
||||||
@@ -496,6 +502,45 @@ dependencies = [
|
|||||||
"inout",
|
"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]]
|
[[package]]
|
||||||
name = "cmov"
|
name = "cmov"
|
||||||
version = "0.5.4"
|
version = "0.5.4"
|
||||||
@@ -4835,13 +4880,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "c6d968cb62160c11f2573e6be724ef8b1b18a277aededd17033f8a912d73e2b4"
|
checksum = "c6d968cb62160c11f2573e6be724ef8b1b18a277aededd17033f8a912d73e2b4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
|
"camino",
|
||||||
"cargo_metadata",
|
"cargo_metadata",
|
||||||
|
"clap",
|
||||||
"uniffi_bindgen",
|
"uniffi_bindgen",
|
||||||
"uniffi_core",
|
"uniffi_core",
|
||||||
"uniffi_macros",
|
"uniffi_macros",
|
||||||
"uniffi_pipeline",
|
"uniffi_pipeline",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "uniffi-bindgen"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"uniffi",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "uniffi_bindgen"
|
name = "uniffi_bindgen"
|
||||||
version = "0.29.4"
|
version = "0.29.4"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
[workspace]
|
[workspace]
|
||||||
members = ["crates/vnidrop"]
|
members = ["crates/vnidrop", "crates/uniffi-bindgen"]
|
||||||
resolver = "2"
|
resolver = "2"
|
||||||
|
|
||||||
[profile.dev]
|
[profile.dev]
|
||||||
|
|||||||
13
apple/.gitignore
vendored
Normal file
13
apple/.gitignore
vendored
Normal 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
43
apple/Package.swift
Normal 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
93
apple/README.md
Normal 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.
|
||||||
|
```
|
||||||
59
apple/Tests/ProgressDerivationTests.swift
Normal file
59
apple/Tests/ProgressDerivationTests.swift
Normal 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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
31
apple/VniDrop/App/AppEnvironment.swift
Normal file
31
apple/VniDrop/App/AppEnvironment.swift
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Platform environment, ported from `Platform.kt` (`PlatformEnvironment`).
|
||||||
|
struct PlatformEnvironment {
|
||||||
|
let name: String
|
||||||
|
let appVersion: String
|
||||||
|
let defaultCoreDataDir: String
|
||||||
|
var defaultUsername: String = "Receiver"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Device info for diagnostics/about, ported from `DeviceInfo`.
|
||||||
|
struct DeviceInfo {
|
||||||
|
let deviceName: String?
|
||||||
|
let deviceModel: String?
|
||||||
|
let operatingSystem: String
|
||||||
|
let network: String?
|
||||||
|
let batteryLevel: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
protocol DeviceInfoProvider {
|
||||||
|
func load() async -> DeviceInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bundle of platform dependencies, ported from `AppDependencies`.
|
||||||
|
struct AppDependencies {
|
||||||
|
let environment: PlatformEnvironment
|
||||||
|
let deviceInfoProvider: DeviceInfoProvider
|
||||||
|
let fileSystemService: FileSystemService
|
||||||
|
let notificationService: LocalNotificationService
|
||||||
|
let externalInvitations: ExternalInvitationController
|
||||||
|
}
|
||||||
43
apple/VniDrop/App/AppGraph.swift
Normal file
43
apple/VniDrop/App/AppGraph.swift
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import Foundation
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
/// Object graph wiring the repositories and coordinators together, ported from
|
||||||
|
/// `AppGraph.kt`. Owned by the app root for the process lifetime.
|
||||||
|
@MainActor
|
||||||
|
final class AppGraph: ObservableObject {
|
||||||
|
let dependencies: AppDependencies
|
||||||
|
let coreRepository: CoreRepository
|
||||||
|
let visibility = AppVisibility()
|
||||||
|
let messages = UiMessageController()
|
||||||
|
let preferencesRepository: AppPreferencesRepository
|
||||||
|
let filePreviewRepository: FilePreviewRepository
|
||||||
|
let approvalCoordinator: ApprovalCoordinator
|
||||||
|
|
||||||
|
init(dependencies: AppDependencies, coreRepository: CoreRepository? = nil) {
|
||||||
|
self.dependencies = dependencies
|
||||||
|
let coreRepository = coreRepository ?? CoreRepository()
|
||||||
|
self.coreRepository = coreRepository
|
||||||
|
self.filePreviewRepository = FilePreviewRepository(appDataDir: dependencies.environment.defaultCoreDataDir)
|
||||||
|
self.preferencesRepository = AppPreferencesRepository(
|
||||||
|
fallback: AppPreferencesDefaults(
|
||||||
|
username: dependencies.environment.defaultUsername,
|
||||||
|
receiveFolder: dependencies.fileSystemService.defaultReceiveFolder(),
|
||||||
|
themeMode: .system,
|
||||||
|
notificationsEnabled: false,
|
||||||
|
diagnosticsEnabled: false
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.approvalCoordinator = ApprovalCoordinator(
|
||||||
|
repository: coreRepository,
|
||||||
|
preferences: preferencesRepository,
|
||||||
|
notifications: dependencies.notificationService,
|
||||||
|
visibility: visibility,
|
||||||
|
messages: messages
|
||||||
|
)
|
||||||
|
AppLogger.info("lifecycle", "graph created", ["platform": dependencies.environment.name])
|
||||||
|
}
|
||||||
|
|
||||||
|
func close() {
|
||||||
|
coreRepository.shutdown()
|
||||||
|
}
|
||||||
|
}
|
||||||
169
apple/VniDrop/App/RootView.swift
Normal file
169
apple/VniDrop/App/RootView.swift
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// App root, ported from `App.kt`. Owns the object graph and feature models, wires
|
||||||
|
/// the adaptive shell, floating actions, snackbar host, and approval modal.
|
||||||
|
struct RootView: View {
|
||||||
|
@StateObject private var graph: AppGraph
|
||||||
|
@StateObject private var appModel: AppModel
|
||||||
|
@StateObject private var sendModel: SendModel
|
||||||
|
@StateObject private var receiveModel: ReceiveModel
|
||||||
|
@StateObject private var settingsModel: SettingsModel
|
||||||
|
@ObservedObject private var messages: UiMessageController
|
||||||
|
@ObservedObject private var approvals: ApprovalCoordinator
|
||||||
|
|
||||||
|
@Environment(\.scenePhase) private var scenePhase
|
||||||
|
|
||||||
|
init(dependencies: AppDependencies) {
|
||||||
|
let graph = AppGraph(dependencies: dependencies)
|
||||||
|
_graph = StateObject(wrappedValue: graph)
|
||||||
|
_appModel = StateObject(wrappedValue: AppModel(
|
||||||
|
environment: dependencies.environment,
|
||||||
|
repository: graph.coreRepository,
|
||||||
|
preferences: graph.preferencesRepository,
|
||||||
|
messages: graph.messages
|
||||||
|
))
|
||||||
|
_sendModel = StateObject(wrappedValue: SendModel(
|
||||||
|
repository: graph.coreRepository,
|
||||||
|
fileSystemService: dependencies.fileSystemService,
|
||||||
|
preferences: graph.preferencesRepository,
|
||||||
|
filePreviewRepository: graph.filePreviewRepository,
|
||||||
|
messages: graph.messages
|
||||||
|
))
|
||||||
|
_receiveModel = StateObject(wrappedValue: ReceiveModel(
|
||||||
|
repository: graph.coreRepository,
|
||||||
|
fileSystemService: dependencies.fileSystemService,
|
||||||
|
preferences: graph.preferencesRepository,
|
||||||
|
messages: graph.messages
|
||||||
|
))
|
||||||
|
_settingsModel = StateObject(wrappedValue: SettingsModel(
|
||||||
|
environment: dependencies.environment,
|
||||||
|
deviceInfoProvider: dependencies.deviceInfoProvider,
|
||||||
|
fileSystemService: dependencies.fileSystemService,
|
||||||
|
preferences: graph.preferencesRepository,
|
||||||
|
notifications: dependencies.notificationService,
|
||||||
|
messages: graph.messages,
|
||||||
|
bugReports: NoopBugReportService()
|
||||||
|
))
|
||||||
|
messages = graph.messages
|
||||||
|
approvals = graph.approvalCoordinator
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
GeometryReader { proxy in
|
||||||
|
let windowClass = windowClassFor(width: proxy.size.width)
|
||||||
|
let isDark = resolveDarkTheme(appModel.themeMode, systemDark: systemDark)
|
||||||
|
ZStack {
|
||||||
|
navigation(windowClass: windowClass)
|
||||||
|
SnackbarHost(controller: messages)
|
||||||
|
ApprovalModalHost(
|
||||||
|
state: approvals.state,
|
||||||
|
onAccept: approvals.accept,
|
||||||
|
onRefuse: approvals.refuse
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.vniDropTheme(isDark: isDark)
|
||||||
|
.preferredColorScheme(appModel.themeMode.preferredColorScheme)
|
||||||
|
.environment(\.vniColors, isDark ? .dark : .light)
|
||||||
|
}
|
||||||
|
.platformPickers(settingsModel: settingsModel)
|
||||||
|
.task { await consumeExternalInvitations() }
|
||||||
|
.onChange(of: scenePhase) { phase in
|
||||||
|
switch phase {
|
||||||
|
case .active:
|
||||||
|
graph.visibility.setForeground(true)
|
||||||
|
settingsModel.refreshNotificationPermission()
|
||||||
|
// Reconcile against the durable snapshot: while the window was
|
||||||
|
// unfocused/occluded (common on macOS) live events may not have
|
||||||
|
// rendered, leaving progress/status stale.
|
||||||
|
Task { _ = await graph.coreRepository.refresh() }
|
||||||
|
case .background, .inactive:
|
||||||
|
graph.visibility.setForeground(false)
|
||||||
|
@unknown default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// iOS uses a bottom tab bar; macOS uses a native source-list sidebar so each
|
||||||
|
/// screen's toolbar lives in the detail column instead of the shared title bar.
|
||||||
|
@ViewBuilder
|
||||||
|
private func navigation(windowClass: WindowClass) -> some View {
|
||||||
|
#if os(macOS)
|
||||||
|
NavigationSplitView {
|
||||||
|
List(AppDestination.allCases, selection: sidebarBinding) { destination in
|
||||||
|
Label(LocalizedStringKey(destination.labelKey), systemImage: destination.systemImage)
|
||||||
|
.tag(destination)
|
||||||
|
}
|
||||||
|
.navigationSplitViewColumnWidth(min: 180, ideal: 200, max: 260)
|
||||||
|
} detail: {
|
||||||
|
screen(for: appModel.destination, windowClass: windowClass)
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
TabView(selection: destinationBinding) {
|
||||||
|
ForEach(AppDestination.allCases) { destination in
|
||||||
|
screen(for: destination, windowClass: windowClass)
|
||||||
|
.tabItem {
|
||||||
|
Label(LocalizedStringKey(destination.labelKey), systemImage: destination.systemImage)
|
||||||
|
}
|
||||||
|
.tag(destination)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
private var sidebarBinding: Binding<AppDestination?> {
|
||||||
|
Binding(
|
||||||
|
get: { appModel.destination },
|
||||||
|
set: { newValue in
|
||||||
|
if let value = newValue {
|
||||||
|
Task { @MainActor in appModel.selectDestination(value) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var destinationBinding: Binding<AppDestination> {
|
||||||
|
// Defer the write out of the current view-update cycle: TabView reconciles
|
||||||
|
// its selection synchronously during body evaluation on macOS, and mutating
|
||||||
|
// the published `destination` there triggers a "publishing within view
|
||||||
|
// updates" warning.
|
||||||
|
Binding(get: { appModel.destination }, set: { newValue in
|
||||||
|
Task { @MainActor in appModel.selectDestination(newValue) }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func screen(for destination: AppDestination, windowClass: WindowClass) -> some View {
|
||||||
|
switch destination {
|
||||||
|
case .send: SendScreen(model: sendModel, windowClass: windowClass)
|
||||||
|
case .receive: ReceiveScreen(model: receiveModel, windowClass: windowClass)
|
||||||
|
case .settings: SettingsScreen(model: settingsModel, windowClass: windowClass)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var systemDark: Bool {
|
||||||
|
#if os(iOS)
|
||||||
|
return UITraitCollection.current.userInterfaceStyle == .dark
|
||||||
|
#else
|
||||||
|
return NSApp.effectiveAppearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
private func consumeExternalInvitations() async {
|
||||||
|
for await invitation in graph.dependencies.externalInvitations.invitations {
|
||||||
|
appModel.selectDestination(.receive)
|
||||||
|
switch invitation {
|
||||||
|
case .success(let raw):
|
||||||
|
receiveModel.onInvitationResult(.invitationFile, .success(raw))
|
||||||
|
case .failure(let error):
|
||||||
|
receiveModel.onInvitationResult(.invitationFile, .failure(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#if os(iOS)
|
||||||
|
import UIKit
|
||||||
|
#else
|
||||||
|
import AppKit
|
||||||
|
#endif
|
||||||
40
apple/VniDrop/App/VniDropApp.swift
Normal file
40
apple/VniDrop/App/VniDropApp.swift
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// App entry point for iOS/iPadOS/macOS, ported from `iOSApp.swift` + `App.kt`.
|
||||||
|
/// Opens `.vnd` invitations via `onOpenURL` and routes them to the receive flow.
|
||||||
|
@main
|
||||||
|
struct VniDropApp: App {
|
||||||
|
@StateObject private var externalInvitations = ExternalInvitationController()
|
||||||
|
|
||||||
|
var body: some Scene {
|
||||||
|
WindowGroup {
|
||||||
|
RootView(dependencies: makeAppDependencies(externalInvitations: externalInvitations))
|
||||||
|
.ignoresSafeArea()
|
||||||
|
.onOpenURL(perform: openInvitation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads a `.vnd` invitation document under a security scope, enforcing the
|
||||||
|
/// 64 KiB / strict-UTF-8 rules from `ContentView.swift`.
|
||||||
|
private func openInvitation(_ url: URL) {
|
||||||
|
guard url.pathExtension.caseInsensitiveCompare(vniDropInvitationExtension) == .orderedSame else {
|
||||||
|
externalInvitations.reportOpenFailure(message: "This is not a VniDrop invitation")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let started = url.startAccessingSecurityScopedResource()
|
||||||
|
defer { if started { url.stopAccessingSecurityScopedResource() } }
|
||||||
|
do {
|
||||||
|
let values = try url.resourceValues(forKeys: [.fileSizeKey])
|
||||||
|
if let size = values.fileSize, size > maxVniDropInvitationBytes {
|
||||||
|
throw InvitationError.tooLarge
|
||||||
|
}
|
||||||
|
let data = try Data(contentsOf: url, options: .mappedIfSafe)
|
||||||
|
let raw = try decodeInvitationBytes(data)
|
||||||
|
externalInvitations.openInvitation(raw: raw)
|
||||||
|
} catch {
|
||||||
|
externalInvitations.reportOpenFailure(
|
||||||
|
message: (error as? LocalizedError)?.errorDescription ?? "The invitation could not be opened"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
24
apple/VniDrop/Core/AppLogger.swift
Normal file
24
apple/VniDrop/Core/AppLogger.swift
Normal 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: " ")
|
||||||
|
}
|
||||||
|
}
|
||||||
135
apple/VniDrop/Core/AppPreferences.swift
Normal file
135
apple/VniDrop/Core/AppPreferences.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
12
apple/VniDrop/Core/AppVisibility.swift
Normal file
12
apple/VniDrop/Core/AppVisibility.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
141
apple/VniDrop/Core/CoreModels.swift
Normal file
141
apple/VniDrop/Core/CoreModels.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
392
apple/VniDrop/Core/CoreRepository.swift
Normal file
392
apple/VniDrop/Core/CoreRepository.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
66
apple/VniDrop/Core/ExternalInvitationController.swift
Normal file
66
apple/VniDrop/Core/ExternalInvitationController.swift
Normal 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
|
||||||
|
}
|
||||||
58
apple/VniDrop/Core/FileSystemService.swift
Normal file
58
apple/VniDrop/Core/FileSystemService.swift
Normal 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 }
|
||||||
|
}
|
||||||
103
apple/VniDrop/Core/LocalNotificationService.swift
Normal file
103
apple/VniDrop/Core/LocalNotificationService.swift
Normal 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
|
||||||
330
apple/VniDrop/Core/TransferProgress.swift
Normal file
330
apple/VniDrop/Core/TransferProgress.swift
Normal 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])
|
||||||
|
}
|
||||||
44
apple/VniDrop/Features/App/AppModel.swift
Normal file
44
apple/VniDrop/Features/App/AppModel.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
179
apple/VniDrop/Features/Approvals/ApprovalCoordinator.swift
Normal file
179
apple/VniDrop/Features/Approvals/ApprovalCoordinator.swift
Normal 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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
73
apple/VniDrop/Features/Approvals/ApprovalModal.swift
Normal file
73
apple/VniDrop/Features/Approvals/ApprovalModal.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
146
apple/VniDrop/Features/Receive/ReceiveInvitationActions.swift
Normal file
146
apple/VniDrop/Features/Receive/ReceiveInvitationActions.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
265
apple/VniDrop/Features/Receive/ReceiveModel.swift
Normal file
265
apple/VniDrop/Features/Receive/ReceiveModel.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
149
apple/VniDrop/Features/Receive/ReceiveScreen.swift
Normal file
149
apple/VniDrop/Features/Receive/ReceiveScreen.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
142
apple/VniDrop/Features/Send/FilePreviewRepository.swift
Normal file
142
apple/VniDrop/Features/Send/FilePreviewRepository.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
285
apple/VniDrop/Features/Send/SendModel.swift
Normal file
285
apple/VniDrop/Features/Send/SendModel.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
191
apple/VniDrop/Features/Send/SendScreen.swift
Normal file
191
apple/VniDrop/Features/Send/SendScreen.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
169
apple/VniDrop/Features/Send/TransferComposer.swift
Normal file
169
apple/VniDrop/Features/Send/TransferComposer.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
328
apple/VniDrop/Features/Send/TransferDetailsView.swift
Normal file
328
apple/VniDrop/Features/Send/TransferDetailsView.swift
Normal 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
|
||||||
61
apple/VniDrop/Features/Send/TransferShareActions.swift
Normal file
61
apple/VniDrop/Features/Send/TransferShareActions.swift
Normal 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() }
|
||||||
|
}
|
||||||
|
}
|
||||||
30
apple/VniDrop/Features/Settings/BugReportService.swift
Normal file
30
apple/VniDrop/Features/Settings/BugReportService.swift
Normal 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
|
||||||
|
}
|
||||||
288
apple/VniDrop/Features/Settings/SettingsModel.swift
Normal file
288
apple/VniDrop/Features/Settings/SettingsModel.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
105
apple/VniDrop/Features/Settings/SettingsScreen.swift
Normal file
105
apple/VniDrop/Features/Settings/SettingsScreen.swift
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
123
apple/VniDrop/Features/Settings/SettingsSections.swift
Normal file
123
apple/VniDrop/Features/Settings/SettingsSections.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
50
apple/VniDrop/Platform/AppDependencies+iOS.swift
Normal file
50
apple/VniDrop/Platform/AppDependencies+iOS.swift
Normal 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
|
||||||
50
apple/VniDrop/Platform/AppDependencies+macOS.swift
Normal file
50
apple/VniDrop/Platform/AppDependencies+macOS.swift
Normal 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
|
||||||
98
apple/VniDrop/Platform/FileSystemService+iOS.swift
Normal file
98
apple/VniDrop/Platform/FileSystemService+iOS.swift
Normal 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
|
||||||
55
apple/VniDrop/Platform/FileSystemService+macOS.swift
Normal file
55
apple/VniDrop/Platform/FileSystemService+macOS.swift
Normal 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
|
||||||
21
apple/VniDrop/Platform/InvitationFile.swift
Normal file
21
apple/VniDrop/Platform/InvitationFile.swift
Normal 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
|
||||||
|
}
|
||||||
119
apple/VniDrop/Platform/PlatformPickers.swift
Normal file
119
apple/VniDrop/Platform/PlatformPickers.swift
Normal 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
262
apple/VniDrop/Platform/ReceiveInvitationActions+iOS.swift
Normal file
262
apple/VniDrop/Platform/ReceiveInvitationActions+iOS.swift
Normal 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
|
||||||
45
apple/VniDrop/Platform/ReceiveInvitationActions+macOS.swift
Normal file
45
apple/VniDrop/Platform/ReceiveInvitationActions+macOS.swift
Normal 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
|
||||||
154
apple/VniDrop/Platform/TransferShareActions+iOS.swift
Normal file
154
apple/VniDrop/Platform/TransferShareActions+iOS.swift
Normal 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
|
||||||
48
apple/VniDrop/Platform/TransferShareActions+macOS.swift
Normal file
48
apple/VniDrop/Platform/TransferShareActions+macOS.swift
Normal 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
|
||||||
@@ -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 |
1
apple/VniDrop/Resources/Assets.xcassets/Contents.json
Normal file
1
apple/VniDrop/Resources/Assets.xcassets/Contents.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{ "info" : { "author" : "xcode", "version" : 1 } }
|
||||||
85
apple/VniDrop/Resources/Info.plist
Normal file
85
apple/VniDrop/Resources/Info.plist
Normal 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>
|
||||||
2208
apple/VniDrop/Resources/Localizable.xcstrings
Normal file
2208
apple/VniDrop/Resources/Localizable.xcstrings
Normal file
File diff suppressed because it is too large
Load Diff
24
apple/VniDrop/Resources/VniDrop.entitlements
Normal file
24
apple/VniDrop/Resources/VniDrop.entitlements
Normal 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>
|
||||||
66
apple/VniDrop/UI/Components/AdaptiveDrawer.swift
Normal file
66
apple/VniDrop/UI/Components/AdaptiveDrawer.swift
Normal 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
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
50
apple/VniDrop/UI/Components/Buttons.swift
Normal file
50
apple/VniDrop/UI/Components/Buttons.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
112
apple/VniDrop/UI/Components/Components.swift
Normal file
112
apple/VniDrop/UI/Components/Components.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
20
apple/VniDrop/UI/Components/PlatformImage.swift
Normal file
20
apple/VniDrop/UI/Components/PlatformImage.swift
Normal 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
|
||||||
79
apple/VniDrop/UI/Feedback/SnackbarHost.swift
Normal file
79
apple/VniDrop/UI/Feedback/SnackbarHost.swift
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Bottom toast host driven by `UiMessageController`, ported from
|
||||||
|
/// `ui/feedback/VniDropSnackbarHost.kt`. Tone drives the accent color; errors get
|
||||||
|
/// a longer display duration.
|
||||||
|
struct SnackbarHost: View {
|
||||||
|
@ObservedObject var controller: UiMessageController
|
||||||
|
@State private var dismissTask: Task<Void, Never>?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack {
|
||||||
|
Spacer()
|
||||||
|
if let message = controller.current {
|
||||||
|
content(for: message)
|
||||||
|
.frame(maxWidth: 520)
|
||||||
|
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 14))
|
||||||
|
.overlay(RoundedRectangle(cornerRadius: 14).stroke(.gray.opacity(0.25), lineWidth: 0.5))
|
||||||
|
.shadow(color: .black.opacity(0.15), radius: 8, y: 2)
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.bottom, 8)
|
||||||
|
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||||
|
.id(message.id)
|
||||||
|
.onAppear { scheduleDismiss(for: message) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.animation(.easeInOut(duration: 0.2), value: controller.current?.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func content(for message: UiMessage) -> some View {
|
||||||
|
let accent: Color = {
|
||||||
|
switch message.tone {
|
||||||
|
case .info: return VniDropColors.brandPurple
|
||||||
|
case .success: return .green
|
||||||
|
case .warning: return .orange
|
||||||
|
case .error: return .red
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
HStack(alignment: .center, spacing: 8) {
|
||||||
|
Circle().fill(accent).frame(width: 8, height: 8)
|
||||||
|
Text(message.text.resolved())
|
||||||
|
.font(.subheadline)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
if let actionLabel = message.actionLabel {
|
||||||
|
Button(action: {
|
||||||
|
message.onAction?()
|
||||||
|
dismiss()
|
||||||
|
}) {
|
||||||
|
Text(actionLabel.resolved()).fontWeight(.semibold)
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderless)
|
||||||
|
}
|
||||||
|
Button(action: dismiss) {
|
||||||
|
Image(systemName: "xmark")
|
||||||
|
.font(.footnote.weight(.semibold))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.frame(width: 36, height: 36)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
.padding(.leading, 16)
|
||||||
|
.padding(.trailing, 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func scheduleDismiss(for message: UiMessage) {
|
||||||
|
dismissTask?.cancel()
|
||||||
|
let seconds: UInt64 = message.tone == .error ? 6 : 4
|
||||||
|
dismissTask = Task {
|
||||||
|
try? await Task.sleep(nanoseconds: seconds * 1_000_000_000)
|
||||||
|
if !Task.isCancelled { controller.advance() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func dismiss() {
|
||||||
|
dismissTask?.cancel()
|
||||||
|
controller.advance()
|
||||||
|
}
|
||||||
|
}
|
||||||
78
apple/VniDrop/UI/Feedback/UiMessage.swift
Normal file
78
apple/VniDrop/UI/Feedback/UiMessage.swift
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import Foundation
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
/// A localizable UI string: either a catalog key or dynamic text, ported from
|
||||||
|
/// `UiText` in `ui/feedback/UiMessageController.kt`.
|
||||||
|
enum UiText: Equatable {
|
||||||
|
case resource(String) // Localizable.xcstrings key
|
||||||
|
case dynamic(String)
|
||||||
|
|
||||||
|
/// Resolves to display text. Keys go through the string catalog.
|
||||||
|
func resolved() -> String {
|
||||||
|
switch self {
|
||||||
|
case .dynamic(let value): return value
|
||||||
|
case .resource(let key): return String(localized: String.LocalizationValue(key))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum UiMessageTone {
|
||||||
|
case info
|
||||||
|
case success
|
||||||
|
case warning
|
||||||
|
case error
|
||||||
|
}
|
||||||
|
|
||||||
|
struct UiMessage: Identifiable {
|
||||||
|
let id = UUID()
|
||||||
|
let text: UiText
|
||||||
|
var tone: UiMessageTone = .info
|
||||||
|
var actionLabel: UiText? = nil
|
||||||
|
var onAction: (() -> Void)? = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Queues user-facing messages (snackbars) and dismissal requests. Ported from
|
||||||
|
/// `UiMessageController.kt`. Errors that are user cancellations are suppressed.
|
||||||
|
@MainActor
|
||||||
|
final class UiMessageController: ObservableObject {
|
||||||
|
@Published private(set) var current: UiMessage?
|
||||||
|
private var queue: [UiMessage] = []
|
||||||
|
|
||||||
|
func show(_ message: UiMessage) {
|
||||||
|
if current == nil {
|
||||||
|
current = message
|
||||||
|
} else {
|
||||||
|
queue.append(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func tryShow(_ message: UiMessage) -> Bool {
|
||||||
|
show(message)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Called by the host when the current message is dismissed or times out.
|
||||||
|
func advance() {
|
||||||
|
if queue.isEmpty {
|
||||||
|
current = nil
|
||||||
|
} else {
|
||||||
|
current = queue.removeFirst()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Surfaces a user-facing error. Logs the technical detail; suppresses user
|
||||||
|
/// cancellations. Mirrors `UiMessageController.error(Throwable)`.
|
||||||
|
func error(_ error: Error) {
|
||||||
|
if error.isUserCancellation {
|
||||||
|
AppLogger.info("ui", "suppressed user cancellation", ["detail": error.technicalDetail])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
AppLogger.error("ui", "user-facing error", error)
|
||||||
|
show(UiMessage(text: error.toUiText(), tone: .error))
|
||||||
|
}
|
||||||
|
|
||||||
|
func error(_ text: UiText) {
|
||||||
|
show(UiMessage(text: text, tone: .error))
|
||||||
|
}
|
||||||
|
}
|
||||||
126
apple/VniDrop/UI/Feedback/UserFacingError.swift
Normal file
126
apple/VniDrop/UI/Feedback/UserFacingError.swift
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
import Foundation
|
||||||
|
import VnidropCore
|
||||||
|
|
||||||
|
/// Maps technical failures to stable, user-facing catalog keys. Ported from
|
||||||
|
/// `ui/feedback/UserFacingError.kt`. Never exposes raw `reason=` blobs.
|
||||||
|
extension Error {
|
||||||
|
func toUiText() -> UiText {
|
||||||
|
if let vni = self as? VnidropError {
|
||||||
|
switch vni {
|
||||||
|
case .Ticket:
|
||||||
|
return .resource("error_invalid_ticket")
|
||||||
|
case .Permission:
|
||||||
|
return .resource("error_permission")
|
||||||
|
case .Filesystem:
|
||||||
|
return .resource("error_filesystem")
|
||||||
|
case .Transfer(let reason):
|
||||||
|
return transferUiText(reason)
|
||||||
|
case .Repository:
|
||||||
|
return .resource("error_repository")
|
||||||
|
case .Initialization(let reason):
|
||||||
|
return initializationUiText(reason)
|
||||||
|
case .Internal(let reason):
|
||||||
|
return reasonHints(reason) ?? .resource("error_generic")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return reasonHints(technicalDetail) ?? .resource("error_generic")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True when the user intentionally backed out of a flow.
|
||||||
|
var isUserCancellation: Bool {
|
||||||
|
let haystack = technicalDetail.lowercased()
|
||||||
|
if haystack.isEmpty {
|
||||||
|
// URLError / CocoaError cancellation without a message.
|
||||||
|
if let urlError = self as? URLError, urlError.code == .cancelled { return true }
|
||||||
|
return (self as NSError).code == NSUserCancelledError
|
||||||
|
}
|
||||||
|
return haystack.contains("cancelled")
|
||||||
|
|| haystack.contains("canceled")
|
||||||
|
|| haystack.contains("user cancelled")
|
||||||
|
|| haystack.contains("user canceled")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Prefers a `VnidropError` reason; else the localized description.
|
||||||
|
var technicalDetail: String {
|
||||||
|
if let vni = self as? VnidropError {
|
||||||
|
switch vni {
|
||||||
|
case .Initialization(let r), .Ticket(let r), .Filesystem(let r),
|
||||||
|
.Transfer(let r), .Permission(let r), .Repository(let r), .Internal(let r):
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (self as? LocalizedError)?.errorDescription ?? (self as NSError).localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func transferUiText(_ reason: String) -> UiText {
|
||||||
|
let detail = reason.lowercased()
|
||||||
|
if detail.contains("refused") || detail.contains("denied") || detail.contains("not approved") {
|
||||||
|
return .resource("error_permission")
|
||||||
|
}
|
||||||
|
return .resource("error_transfer")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func initializationUiText(_ reason: String) -> UiText {
|
||||||
|
let detail = reason.lowercased()
|
||||||
|
if detail.contains("native") && detail.contains("library") {
|
||||||
|
return .resource("error_missing_native_library")
|
||||||
|
}
|
||||||
|
if detail.contains("socket") || detail.contains("bind") {
|
||||||
|
return .resource("error_socket_bind")
|
||||||
|
}
|
||||||
|
return .resource("error_initialization")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func reasonHints(_ detailRaw: String) -> UiText? {
|
||||||
|
let detail = detailRaw.lowercased()
|
||||||
|
if detail.isEmpty { return nil }
|
||||||
|
|
||||||
|
if detail.contains("still starting") || detail.contains("starting up") {
|
||||||
|
return .resource("error_starting_up")
|
||||||
|
}
|
||||||
|
if detail.contains("empty") && (detail.contains("invitation") || detail.contains("ticket") || detail.contains("qr")) {
|
||||||
|
return .resource("error_invitation_empty")
|
||||||
|
}
|
||||||
|
if detail.contains("select at least one") || detail.contains("no files found") {
|
||||||
|
return .resource("error_share_empty")
|
||||||
|
}
|
||||||
|
if detail.contains("camera") {
|
||||||
|
return .resource("error_camera")
|
||||||
|
}
|
||||||
|
if detail.contains("nfc") || detail.contains("ndef")
|
||||||
|
|| (detail.contains("read-only") && detail.contains("tag"))
|
||||||
|
|| detail.contains("tag is too small") || detail.contains("no nfc tag") {
|
||||||
|
return .resource("error_nfc")
|
||||||
|
}
|
||||||
|
if detail.contains("native") && detail.contains("library") {
|
||||||
|
return .resource("error_missing_native_library")
|
||||||
|
}
|
||||||
|
if detail.contains("socket") || detail.contains("bind") {
|
||||||
|
return .resource("error_socket_bind")
|
||||||
|
}
|
||||||
|
if detail.contains("device information") || detail.contains("device info") {
|
||||||
|
return .resource("error_device_info")
|
||||||
|
}
|
||||||
|
if detail.contains("refused") || detail.contains("denied") || detail.contains("permission")
|
||||||
|
|| detail.contains("not approved") || detail.contains("waiting for approval") {
|
||||||
|
return .resource("error_permission")
|
||||||
|
}
|
||||||
|
if detail.contains("invalid ticket") || detail.contains("ticket error")
|
||||||
|
|| detail.contains("could not be read") || detail.contains("malformed")
|
||||||
|
|| detail.contains("invitation could not be opened") {
|
||||||
|
return .resource("error_invalid_ticket")
|
||||||
|
}
|
||||||
|
if detail.contains("selected")
|
||||||
|
&& (detail.contains("file") || detail.contains("folder") || detail.contains("document") || detail.contains("open")) {
|
||||||
|
return .resource("error_selection_failed")
|
||||||
|
}
|
||||||
|
if detail.contains("could not open the selected") || detail.contains("could not open selected") {
|
||||||
|
return .resource("error_selection_failed")
|
||||||
|
}
|
||||||
|
if detail.contains("document picker") || detail.contains("folder picker") || detail.contains("file descriptor")
|
||||||
|
|| detail.contains("view controller") {
|
||||||
|
return .resource("error_selection_failed")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
27
apple/VniDrop/UI/Navigation/AppDestination.swift
Normal file
27
apple/VniDrop/UI/Navigation/AppDestination.swift
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
11
apple/VniDrop/UI/Theme/Typography.swift
Normal file
11
apple/VniDrop/UI/Theme/Typography.swift
Normal 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)
|
||||||
|
}
|
||||||
189
apple/VniDrop/UI/Theme/VniDropColors.swift
Normal file
189
apple/VniDrop/UI/Theme/VniDropColors.swift
Normal 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))
|
||||||
|
}
|
||||||
56
apple/VniDrop/UI/Theme/VniDropTheme.swift
Normal file
56
apple/VniDrop/UI/Theme/VniDropTheme.swift
Normal 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
30
apple/VnidropCore/Package.swift
Normal file
30
apple/VnidropCore/Package.swift
Normal 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"]
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
25
apple/VnidropCore/README.md
Normal file
25
apple/VnidropCore/README.md
Normal 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
46
apple/project.yml
Normal 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
104
apple/scripts/build-core.sh
Executable 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"
|
||||||
16
crates/uniffi-bindgen/Cargo.toml
Normal file
16
crates/uniffi-bindgen/Cargo.toml
Normal 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"] }
|
||||||
3
crates/uniffi-bindgen/src/main.rs
Normal file
3
crates/uniffi-bindgen/src/main.rs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
fn main() {
|
||||||
|
uniffi::uniffi_bindgen_main()
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user