diff --git a/.github/workflows/apple.yml b/.github/workflows/apple.yml new file mode 100644 index 0000000..55e9d26 --- /dev/null +++ b/.github/workflows/apple.yml @@ -0,0 +1,79 @@ +name: Apple + +on: + pull_request: + paths: + - "apple/**" + - "crates/vnidrop/**" + - "crates/uniffi-bindgen/**" + - "Cargo.toml" + - "Cargo.lock" + - ".github/workflows/apple.yml" + push: + branches: + - master + paths: + - "apple/**" + - "crates/vnidrop/**" + - "crates/uniffi-bindgen/**" + - "Cargo.toml" + - "Cargo.lock" + - ".github/workflows/apple.yml" + +permissions: + contents: read + +concurrency: + group: apple-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + build-test: + runs-on: macos-latest + timeout-minutes: 75 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Select Xcode + # Pin so the simulator device name below stays predictable. + run: sudo xcode-select -s /Applications/Xcode.app + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + targets: aarch64-apple-ios,aarch64-apple-ios-sim,x86_64-apple-ios,aarch64-apple-darwin + + - name: Cache Cargo + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: apple-cargo-${{ hashFiles('Cargo.lock') }} + restore-keys: apple-cargo- + + - name: Install XcodeGen + run: brew install xcodegen + + - name: Build Rust core (xcframework + Swift bindings) + working-directory: apple + run: ./scripts/build-core.sh debug + + - name: Generate Xcode project + working-directory: apple + run: xcodegen generate + + - name: Run unit tests (iOS Simulator) + working-directory: apple + run: | + set -euo pipefail + DEVICE=$(xcrun simctl list devices available \ + | grep -oE 'iPhone [0-9]+( Pro)?' | head -1) + echo "Testing on: ${DEVICE:-iPhone 16}" + xcodebuild test \ + -project VniDrop.xcodeproj \ + -scheme VniDrop \ + -destination "platform=iOS Simulator,name=${DEVICE:-iPhone 16}" \ + CODE_SIGNING_ALLOWED=NO diff --git a/Cargo.lock b/Cargo.lock index 1261b9b..9424d1d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -61,6 +61,12 @@ dependencies = [ "libc", ] +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + [[package]] name = "anyhow" version = "1.0.103" @@ -496,6 +502,45 @@ dependencies = [ "inout", ] +[[package]] +name = "clap" +version = "4.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd059f9da4f5c36b3787f65d38ccaab1cc315f07b01f89abc8359ee6a8205011" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f09628afdcc538b57f3c6341e9c8e9970f18e4a481690a64974d7023bd33548b" +dependencies = [ + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + [[package]] name = "cmov" version = "0.5.4" @@ -4835,13 +4880,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6d968cb62160c11f2573e6be724ef8b1b18a277aededd17033f8a912d73e2b4" dependencies = [ "anyhow", + "camino", "cargo_metadata", + "clap", "uniffi_bindgen", "uniffi_core", "uniffi_macros", "uniffi_pipeline", ] +[[package]] +name = "uniffi-bindgen" +version = "0.1.0" +dependencies = [ + "uniffi", +] + [[package]] name = "uniffi_bindgen" version = "0.29.4" diff --git a/Cargo.toml b/Cargo.toml index 130a431..3aaf53c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["crates/vnidrop"] +members = ["crates/vnidrop", "crates/uniffi-bindgen"] resolver = "2" [profile.dev] diff --git a/apple/.gitignore b/apple/.gitignore new file mode 100644 index 0000000..3910689 --- /dev/null +++ b/apple/.gitignore @@ -0,0 +1,16 @@ +# 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/ + +# Per-developer signing (team id); Signing.xcconfig optionally includes it +Local.xcconfig + +# SwiftPM / Xcode +.build/ +.swiftpm/ +DerivedData/ +*.xcuserstate diff --git a/apple/Package.swift b/apple/Package.swift new file mode 100644 index 0000000..94b94ba --- /dev/null +++ b/apple/Package.swift @@ -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" + ), + ] +) diff --git a/apple/README.md b/apple/README.md new file mode 100644 index 0000000..205e549 --- /dev/null +++ b/apple/README.md @@ -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. +``` diff --git a/apple/Signing.xcconfig b/apple/Signing.xcconfig new file mode 100644 index 0000000..39d058f --- /dev/null +++ b/apple/Signing.xcconfig @@ -0,0 +1,9 @@ +// Committed signing config. Contains no secrets. +// +// Per-developer signing (e.g. DEVELOPMENT_TEAM) goes in Local.xcconfig, which is +// gitignored. The optional include below means the build still works for anyone +// who doesn't have a Local.xcconfig — Xcode automatic signing fills in their team. +// +// To persist your team across `xcodegen generate`, create apple/Local.xcconfig: +// DEVELOPMENT_TEAM = XXXXXXXXXX +#include? "Local.xcconfig" diff --git a/apple/Tests/AppModelTests.swift b/apple/Tests/AppModelTests.swift new file mode 100644 index 0000000..24da979 --- /dev/null +++ b/apple/Tests/AppModelTests.swift @@ -0,0 +1,41 @@ +import XCTest +@testable import VniDrop + +/// Ports app-level assertions: core initialization on launch, destination +/// selection guard, and theme following preferences. +@MainActor +final class AppModelTests: XCTestCase { + + private func makeModel(_ core: FakeCoreGateway, preferences: AppPreferencesRepository) -> AppModel { + AppModel( + environment: PlatformEnvironment(name: "Test", appVersion: "0.1.0", defaultCoreDataDir: NSTemporaryDirectory()), + repository: core, + preferences: preferences, + messages: UiMessageController() + ) + } + + func testInitializesCoreOnLaunch() async { + let core = FakeCoreGateway() + _ = makeModel(core, preferences: Fixtures.preferences()) + await waitUntil { core.state.isInitialized } + XCTAssertTrue(core.state.isInitialized) + } + + func testSelectDestination() { + let model = makeModel(FakeCoreGateway(), preferences: Fixtures.preferences()) + XCTAssertEqual(model.destination, .send) + model.selectDestination(.settings) + XCTAssertEqual(model.destination, .settings) + model.selectDestination(.settings) // no-op guard + XCTAssertEqual(model.destination, .settings) + } + + func testThemeModeFollowsPreferences() async { + let prefs = Fixtures.preferences() + let model = makeModel(FakeCoreGateway(), preferences: prefs) + prefs.setThemeMode(.dark) + await waitUntil { model.themeMode == .dark } + XCTAssertEqual(model.themeMode, .dark) + } +} diff --git a/apple/Tests/AppPreferencesRepositoryTests.swift b/apple/Tests/AppPreferencesRepositoryTests.swift new file mode 100644 index 0000000..eb4ef11 --- /dev/null +++ b/apple/Tests/AppPreferencesRepositoryTests.swift @@ -0,0 +1,51 @@ +import XCTest +@testable import VniDrop + +/// Ports `preferences/AppPreferencesRepositoryTest.kt` — values persist to the +/// backing store and reload identically. +@MainActor +final class AppPreferencesRepositoryTests: XCTestCase { + + private func defaults() -> UserDefaults { UserDefaults(suiteName: "vnidrop.prefs.\(UUID().uuidString)")! } + private func fallback() -> AppPreferencesDefaults { + AppPreferencesDefaults( + username: "Default", + receiveFolder: ReceiveFolder(kind: .fileSystemPath, value: "/tmp", displayName: "Downloads"), + themeMode: .system + ) + } + + func testFallbacksWhenEmpty() { + let repo = AppPreferencesRepository(defaults: defaults(), fallback: fallback()) + XCTAssertEqual(repo.preferences.username, "Default") + XCTAssertEqual(repo.preferences.themeMode, .system) + XCTAssertFalse(repo.preferences.notificationsEnabled) + } + + func testValuesPersistAndReload() { + let store = defaults() + let fb = fallback() + let repo = AppPreferencesRepository(defaults: store, fallback: fb) + repo.setUsername("Bob") + repo.setThemeMode(.dark) + repo.setNotificationsEnabled(true) + repo.setReceiveFolder(ReceiveFolder(kind: .iosSecurityScopedUrl, value: "file:///x", displayName: "Custom")) + + // A fresh repository over the same store reflects the persisted values. + let reloaded = AppPreferencesRepository(defaults: store, fallback: fb) + XCTAssertEqual(reloaded.preferences.username, "Bob") + XCTAssertEqual(reloaded.preferences.themeMode, .dark) + XCTAssertTrue(reloaded.preferences.notificationsEnabled) + XCTAssertEqual(reloaded.preferences.receiveFolder.displayName, "Custom") + XCTAssertEqual(reloaded.preferences.receiveFolder.kind, .iosSecurityScopedUrl) + } + + func testResetReceiveFolderRestoresFallback() { + let store = defaults() + let fb = fallback() + let repo = AppPreferencesRepository(defaults: store, fallback: fb) + repo.setReceiveFolder(ReceiveFolder(kind: .fileSystemPath, value: "/custom", displayName: "Custom")) + repo.resetReceiveFolder() + XCTAssertEqual(repo.preferences.receiveFolder.value, "/tmp") + } +} diff --git a/apple/Tests/ApprovalCoordinatorTests.swift b/apple/Tests/ApprovalCoordinatorTests.swift new file mode 100644 index 0000000..97ac8c0 --- /dev/null +++ b/apple/Tests/ApprovalCoordinatorTests.swift @@ -0,0 +1,69 @@ +import XCTest +import Combine +@testable import VniDrop + +/// Ports `feature/approvals/ApprovalCoordinatorTest.kt` (the gateway-observable +/// parts; notification assertions require a notification-service seam we don't +/// have on Apple yet). +@MainActor +final class ApprovalCoordinatorTests: XCTestCase { + + private func makeCoordinator(_ core: FakeCoreGateway) -> ApprovalCoordinator { + ApprovalCoordinator( + repository: core, + preferences: Fixtures.preferences(), + notifications: LocalNotificationService(), + visibility: AppVisibility(), + messages: UiMessageController() + ) + } + + func testOrdersPendingRequestsByRequestedAt() async { + let core = FakeCoreGateway() + core.requests[1] = [Fixtures.request(id: "new", requestedAt: 20), + Fixtures.request(id: "old", requestedAt: 10)] + let coordinator = makeCoordinator(core) + + core.setState(CoreState(isInitialized: true, transfers: [Fixtures.transfer(id: 1, direction: .send, status: .sharing)])) + core.emit(.approvalChanged(transferId: 1)) + + await waitUntil { !coordinator.state.pending.isEmpty } + XCTAssertEqual(coordinator.state.pending.map(\.id), ["old", "new"]) + XCTAssertEqual(coordinator.state.current?.id, "old") + } + + func testFailedResponseKeepsRequestVisibleAndClearsResponding() async { + let core = FakeCoreGateway() + core.requests[1] = [Fixtures.request(id: "request", requestedAt: 10)] + core.responseResult = .failure(TestError.unimplemented) + let coordinator = makeCoordinator(core) + + core.setState(CoreState(isInitialized: true, transfers: [Fixtures.transfer(id: 1, direction: .send, status: .sharing)])) + core.emit(.approvalChanged(transferId: 1)) + await waitUntil { coordinator.state.pending.contains { $0.id == "request" } } + + coordinator.accept("request") + await waitUntil { coordinator.state.respondingIds.isEmpty && core.responses.count == 1 } + + XCTAssertTrue(coordinator.state.pending.contains { $0.id == "request" }) + XCTAssertTrue(coordinator.state.respondingIds.isEmpty) + XCTAssertEqual(core.responses.first?.accepted, true) + } + + func testAcceptRespondsPositivelyAndSingleFlights() async { + let core = FakeCoreGateway() + core.requests[1] = [Fixtures.request(id: "request", requestedAt: 10)] + let coordinator = makeCoordinator(core) + core.setState(CoreState(isInitialized: true, transfers: [Fixtures.transfer(id: 1, direction: .send, status: .sharing)])) + core.emit(.approvalChanged(transferId: 1)) + await waitUntil { coordinator.state.current != nil } + + coordinator.accept("request") + coordinator.accept("request") // second call must be ignored (single-flight) + await waitUntil { core.responses.count >= 1 } + try? await Task.sleep(nanoseconds: 50_000_000) + + XCTAssertEqual(core.responses.count, 1) + XCTAssertEqual(core.responses.first?.id, "request") + } +} diff --git a/apple/Tests/Fakes.swift b/apple/Tests/Fakes.swift new file mode 100644 index 0000000..33d5ba1 --- /dev/null +++ b/apple/Tests/Fakes.swift @@ -0,0 +1,137 @@ +import Foundation +import Combine +import VnidropCore +@testable import VniDrop + +enum TestError: Error { case unimplemented } + +/// In-memory `CoreGateway`, mirroring `support/Fakes.kt`'s `FakeCoreGateway`. +/// Lets model tests drive core state/signals and stub results without the FFI. +@MainActor +final class FakeCoreGateway: CoreGateway { + private let stateSubject = CurrentValueSubject(CoreState()) + private let signalsSubject = PassthroughSubject() + + var state: CoreState { stateSubject.value } + var statePublisher: AnyPublisher { stateSubject.eraseToAnyPublisher() } + var signals: AnyPublisher { signalsSubject.eraseToAnyPublisher() } + + // Stubbed results + var requests: [UInt64: [ReceiverRequestModel]] = [:] + var responseResult: Result = .success(()) + var shareResult: Result = .failure(TestError.unimplemented) + var inspectionResult: Result = .failure(TestError.unimplemented) + var receiveResult: Result = .success(()) + var cancelResult: Result = .success(()) + var deleteResult: Result = .success(()) + var clearReceiveHistoryResult: Result = .success(0) + + // Recorded calls + private(set) var responses: [(id: String, accepted: Bool, reason: String?)] = [] + private(set) var deletedTransfers: [UInt64] = [] + private(set) var cancelledTransfers: [UInt64] = [] + private(set) var clearReceiveHistoryCount = 0 + private(set) var receiveCount = 0 + private(set) var lastReceiveTicket: String? + private(set) var lastReceiveReceiverName: String? + private(set) var lastShareAccessPolicy: ShareAccessPolicy? + + func setState(_ state: CoreState) { stateSubject.send(state) } + func emit(_ signal: CoreSignal) { signalsSubject.send(signal) } + + func initialize(appDataDir: String) async -> Result { + var s = stateSubject.value + s.isInitialized = true + stateSubject.send(s) + return .success(()) + } + func shutdown() {} + func shareSources(_ sources: [ShareSource], transferName: String, senderName: String, accessPolicy: ShareAccessPolicy) async -> Result { + lastShareAccessPolicy = accessPolicy + return shareResult + } + func inspectTicket(_ ticket: String) async -> Result { inspectionResult } + func receive(ticket: String, outputDir: String, receiverName: String) async -> Result { + receiveCount += 1; lastReceiveTicket = ticket; lastReceiveReceiverName = receiverName + return receiveResult + } + func receiveIntoSecurityScopedDirectory(ticket: String, outputDirectoryUrl: String, receiverName: String) async -> Result { + receiveCount += 1; lastReceiveTicket = ticket; lastReceiveReceiverName = receiverName + return receiveResult + } + func cancel(transferId: UInt64) async -> Result { cancelledTransfers.append(transferId); return cancelResult } + func delete(transferId: UInt64) async -> Result { deletedTransfers.append(transferId); return deleteResult } + func clearReceiveHistory() async -> Result { clearReceiveHistoryCount += 1; return clearReceiveHistoryResult } + func receiverRequests(transferId: UInt64) async -> Result<[ReceiverRequestModel], Error> { .success(requests[transferId] ?? []) } + func respondReceiverRequest(requestId: String, accepted: Bool, reason: String?) async -> Result { + responses.append((requestId, accepted, reason)) + return responseResult + } + func refresh() async -> Result { .success(()) } +} + +/// Minimal `FileSystemService` fake — a writable path receive folder, no reveal. +@MainActor +final class FakeFileSystemService: FileSystemService { + var supportsCustomReceiveFolders = false + var folder = ReceiveFolder(kind: .fileSystemPath, value: "/tmp/vnidrop-tests", displayName: "Documents") + + func defaultReceiveFolder() -> ReceiveFolder { folder } + func validateReceiveFolder(_ folder: ReceiveFolder) async -> FolderAccessStatus { .writable } + func canRevealReceiveFolder(_ folder: ReceiveFolder) -> Bool { false } + func sharePickedFiles(repository: CoreGateway, files: [PickedShareFile], transferName: String, senderName: String, accessPolicy: ShareAccessPolicy) async -> Result { + await repository.shareSources([], transferName: transferName, senderName: senderName, accessPolicy: accessPolicy) + } +} + +@MainActor +final class FakeDeviceInfoProvider: DeviceInfoProvider { + func load() async -> DeviceInfo { + DeviceInfo(deviceName: "Test Device", deviceModel: "TestModel", + operatingSystem: "TestOS 1.0", network: nil, batteryLevel: nil) + } +} + +// MARK: - Factories + +@MainActor +enum Fixtures { + static func preferences(username: String = "Tester") -> AppPreferencesRepository { + let defaults = UserDefaults(suiteName: "vnidrop.tests.\(UUID().uuidString)")! + return AppPreferencesRepository( + defaults: defaults, + fallback: AppPreferencesDefaults( + username: username, + receiveFolder: ReceiveFolder(kind: .fileSystemPath, value: "/tmp/vnidrop-tests", displayName: "Documents"), + themeMode: .system + ) + ) + } + + static func request(id: String, requestedAt: Int64, transferId: UInt64 = 1, status: ReceiverDeliveryStatus = .requested) -> ReceiverRequestModel { + ReceiverRequestModel( + id: id, transferId: transferId, remoteEndpointId: "endpoint-\(id)", + transferName: "Photos", receiverName: "Peer", receiverDeviceName: "Phone", + appVersion: "1.0", status: status, reason: nil, + requestedAt: requestedAt, respondedAt: nil, completedAt: nil + ) + } + + static func transfer(id: UInt64, direction: TransferDirection, status: TransferStatus) -> Transfer { + Transfer( + localId: "local-\(id)", transferId: id, direction: direction, status: status, + peerId: nil, transferName: "Photos", contentHash: nil, fileCount: 1, totalSize: 1024, + ticket: "ticket", accessPolicy: .requireApproval, createdAt: 0, updatedAt: 0 + ) + } +} + +/// Polls `condition` on the main actor until true or `timeout` elapses. Used to +/// await the models' internal `Task`s, which XCTest can't join directly. +@MainActor +func waitUntil(timeout: TimeInterval = 2, _ condition: @escaping () -> Bool) async { + let deadline = Date().addingTimeInterval(timeout) + while !condition() && Date() < deadline { + try? await Task.sleep(nanoseconds: 5_000_000) + } +} diff --git a/apple/Tests/FilePreviewRepositoryTests.swift b/apple/Tests/FilePreviewRepositoryTests.swift new file mode 100644 index 0000000..3dc4587 --- /dev/null +++ b/apple/Tests/FilePreviewRepositoryTests.swift @@ -0,0 +1,43 @@ +import XCTest +@testable import VniDrop + +/// Ports `feature/send/FilePreviewRepositoryTest.kt` — persisted thumbnails, +/// restore pruned to live transfer ids, and removal. +@MainActor +final class FilePreviewRepositoryTests: XCTestCase { + + /// Minimal bytes that pass the PNG magic-byte check. + private let png = Data([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]) + + private func makeRepo() -> FilePreviewRepository { + FilePreviewRepository(appDataDir: NSTemporaryDirectory() + "previews-" + UUID().uuidString) + } + + func testSaveStoresPreview() { + let repo = makeRepo() + repo.save(transferId: 1, bytes: png) + XCTAssertEqual(repo.previews[1], png) + } + + func testSaveRejectsNonImageBytes() { + let repo = makeRepo() + repo.save(transferId: 1, bytes: Data("not an image".utf8)) + XCTAssertNil(repo.previews[1]) + } + + func testRestorePrunesToActiveIds() { + let repo = makeRepo() + repo.save(transferId: 1, bytes: png) + repo.save(transferId: 2, bytes: png) + repo.restore(activeTransferIds: [1]) + XCTAssertEqual(repo.previews[1], png) + XCTAssertNil(repo.previews[2]) + } + + func testRemoveDeletesPreview() { + let repo = makeRepo() + repo.save(transferId: 1, bytes: png) + repo.remove(transferId: 1) + XCTAssertNil(repo.previews[1]) + } +} diff --git a/apple/Tests/InvitationTests.swift b/apple/Tests/InvitationTests.swift new file mode 100644 index 0000000..1a50832 --- /dev/null +++ b/apple/Tests/InvitationTests.swift @@ -0,0 +1,43 @@ +import XCTest +@testable import VniDrop + +/// Ports `feature/receive/ExternalInvitationControllerTest.kt` + the `.vnd` +/// decode/filename helpers. +@MainActor +final class InvitationTests: XCTestCase { + + func testValidateInvitationAcceptsValid() { + guard case .success(let raw) = validateInvitation("some-ticket") else { return XCTFail("expected success") } + XCTAssertEqual(raw, "some-ticket") + } + + func testValidateRejectsEmpty() { + guard case .failure(let error) = validateInvitation(" \n ") else { return XCTFail("expected failure") } + XCTAssertTrue((error as? InvitationError) != nil) + } + + func testValidateRejectsTooLarge() { + let big = String(repeating: "a", count: maxVniDropInvitationBytes + 1) + guard case .failure = validateInvitation(big) else { return XCTFail("expected failure") } + } + + func testDecodeInvitationBytesRoundTrip() throws { + let text = "vnidrop://ticket-abc" + let decoded = try decodeInvitationBytes(Data(text.utf8)) + XCTAssertEqual(decoded, text) + } + + func testDecodeRejectsEmptyData() { + XCTAssertThrowsError(try decodeInvitationBytes(Data())) + } + + func testDecodeRejectsInvalidUtf8() { + XCTAssertThrowsError(try decodeInvitationBytes(Data([0xFF, 0xFE, 0xFD]))) + } + + func testInvitationFileNameSanitizes() { + XCTAssertEqual(invitationFileName("My Photos"), "My-Photos.vnd") + XCTAssertEqual(invitationFileName(" "), "invitation.vnd") + XCTAssertTrue(invitationFileName("a/b:c*d").hasSuffix(".vnd")) + } +} diff --git a/apple/Tests/ProgressDerivationTests.swift b/apple/Tests/ProgressDerivationTests.swift new file mode 100644 index 0000000..431eb1d --- /dev/null +++ b/apple/Tests/ProgressDerivationTests.swift @@ -0,0 +1,59 @@ +import XCTest +@testable import VniDrop + +/// 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 + ) + } +} diff --git a/apple/Tests/ReceiveModelTests.swift b/apple/Tests/ReceiveModelTests.swift new file mode 100644 index 0000000..0a83491 --- /dev/null +++ b/apple/Tests/ReceiveModelTests.swift @@ -0,0 +1,67 @@ +import XCTest +@testable import VniDrop + +/// Ports the receive-side state-machine assertions from `feature/ViewModelsTest.kt`. +@MainActor +final class ReceiveModelTests: XCTestCase { + + private func makeModel(_ core: FakeCoreGateway) -> ReceiveModel { + ReceiveModel( + repository: core, + fileSystemService: FakeFileSystemService(), + preferences: Fixtures.preferences(), + messages: UiMessageController() + ) + } + + func testDeleteHistoryItemDeletesTerminalReceiveTransfer() async { + let core = FakeCoreGateway() + let model = makeModel(core) + core.setState(CoreState(isInitialized: true, transfers: [Fixtures.transfer(id: 5, direction: .receive, status: .done)])) + await waitUntil { model.coreState.transfers.contains { $0.transferId == 5 } } + + model.requestDeleteHistoryItem(5) + XCTAssertEqual(model.state.historyDeleteTarget, .transfer(transferId: 5)) + + model.confirmHistoryDelete() + await waitUntil { core.deletedTransfers.contains(5) } + XCTAssertEqual(core.deletedTransfers, [5]) + XCTAssertNil(model.state.historyDeleteTarget) + } + + func testClearHistoryCallsClearReceiveHistory() async { + let core = FakeCoreGateway() + let model = makeModel(core) + core.setState(CoreState(isInitialized: true, transfers: [Fixtures.transfer(id: 5, direction: .receive, status: .done)])) + await waitUntil { !model.coreState.transfers.isEmpty } + + model.requestClearHistory() + XCTAssertEqual(model.state.historyDeleteTarget, .all) + + model.confirmHistoryDelete() + await waitUntil { core.clearReceiveHistoryCount == 1 } + XCTAssertEqual(core.clearReceiveHistoryCount, 1) + XCTAssertNil(model.state.historyDeleteTarget) + } + + func testDeleteHistoryItemIgnoresNonTerminalTransfer() async { + let core = FakeCoreGateway() + let model = makeModel(core) + core.setState(CoreState(isInitialized: true, transfers: [Fixtures.transfer(id: 9, direction: .receive, status: .receiving)])) + await waitUntil { !model.coreState.transfers.isEmpty } + + model.requestDeleteHistoryItem(9) + XCTAssertNil(model.state.historyDeleteTarget) // in-flight receive can't be deleted from history + } + + func testCancelActiveReceiveCancelsTheReceivingTransfer() async { + let core = FakeCoreGateway() + let model = makeModel(core) + core.setState(CoreState(isInitialized: true, transfers: [Fixtures.transfer(id: 7, direction: .receive, status: .receiving)])) + await waitUntil { !model.coreState.transfers.isEmpty } + + model.cancelActiveReceive() + await waitUntil { core.cancelledTransfers.contains(7) } + XCTAssertEqual(core.cancelledTransfers, [7]) + } +} diff --git a/apple/Tests/SendModelTests.swift b/apple/Tests/SendModelTests.swift new file mode 100644 index 0000000..616ae67 --- /dev/null +++ b/apple/Tests/SendModelTests.swift @@ -0,0 +1,58 @@ +import XCTest +@testable import VniDrop + +/// Ports the send-side state-machine assertions from `feature/ViewModelsTest.kt`. +@MainActor +final class SendModelTests: XCTestCase { + + private func makeModel(_ core: FakeCoreGateway) -> SendModel { + SendModel( + repository: core, + fileSystemService: FakeFileSystemService(), + preferences: Fixtures.preferences(), + filePreviewRepository: FilePreviewRepository(appDataDir: NSTemporaryDirectory() + UUID().uuidString), + messages: UiMessageController() + ) + } + + func testOpenAndCloseTransferDetails() { + let model = makeModel(FakeCoreGateway()) + model.openTransfer(3) + XCTAssertEqual(model.state.selectedTransferId, 3) + model.closeTransferDetails() + XCTAssertNil(model.state.selectedTransferId) + } + + func testDeleteTransferConfirmationFlow() async { + let core = FakeCoreGateway() + let model = makeModel(core) + model.openTransfer(3) + + model.requestDeleteTransfer() + XCTAssertTrue(model.state.isDeleteConfirmationOpen) + + model.confirmDeleteTransfer() + await waitUntil { core.deletedTransfers.contains(3) } + XCTAssertEqual(core.deletedTransfers, [3]) + XCTAssertNil(model.state.selectedTransferId) + XCTAssertFalse(model.state.isDeleteConfirmationOpen) + } + + func testStopSharingCancelsTheTransfer() async { + let core = FakeCoreGateway() + let model = makeModel(core) + model.stopSharing(transferId: 4) + await waitUntil { core.cancelledTransfers.contains(4) } + XCTAssertEqual(core.cancelledTransfers, [4]) + } + + func testCancelReceiverRefusesTheRequest() async { + let core = FakeCoreGateway() + let model = makeModel(core) + model.openTransfer(1) + model.cancelReceiver(requestId: "req-1") + await waitUntil { core.responses.contains { $0.id == "req-1" } } + let response = core.responses.first { $0.id == "req-1" } + XCTAssertEqual(response?.accepted, false) + } +} diff --git a/apple/Tests/SettingsModelTests.swift b/apple/Tests/SettingsModelTests.swift new file mode 100644 index 0000000..a6623f1 --- /dev/null +++ b/apple/Tests/SettingsModelTests.swift @@ -0,0 +1,45 @@ +import XCTest +@testable import VniDrop + +/// Ports settings assertions from `feature/ViewModelsTest.kt` — username debounce +/// persistence and the Storage "delete all transfers" flow. +@MainActor +final class SettingsModelTests: XCTestCase { + + private func makeModel(_ core: FakeCoreGateway, preferences: AppPreferencesRepository) -> SettingsModel { + SettingsModel( + environment: PlatformEnvironment(name: "Test", appVersion: "0.1.0", defaultCoreDataDir: NSTemporaryDirectory()), + deviceInfoProvider: FakeDeviceInfoProvider(), + fileSystemService: FakeFileSystemService(), + repository: core, + preferences: preferences, + notifications: LocalNotificationService(), + messages: UiMessageController(), + bugReports: NoopBugReportService(), + diagnosticsIncluded: false + ) + } + + func testUsernameChangeDebouncesAndPersists() async { + let prefs = Fixtures.preferences(username: "Original") + let model = makeModel(FakeCoreGateway(), preferences: prefs) + + model.setUsername("Alice") + XCTAssertEqual(model.state.username, "Alice") // immediate local echo + await waitUntil { prefs.preferences.username == "Alice" } // persisted after debounce + XCTAssertEqual(prefs.preferences.username, "Alice") + } + + func testDeleteAllTransfersDeletesEveryTransfer() async { + let core = FakeCoreGateway() + let model = makeModel(core, preferences: Fixtures.preferences()) + core.setState(CoreState(isInitialized: true, transfers: [ + Fixtures.transfer(id: 2, direction: .send, status: .sharing), + Fixtures.transfer(id: 3, direction: .receive, status: .done), + ])) + + model.deleteAllTransfers() + await waitUntil { core.deletedTransfers.count == 2 } + XCTAssertEqual(Set(core.deletedTransfers), [2, 3]) + } +} diff --git a/apple/Tests/UiFeedbackTests.swift b/apple/Tests/UiFeedbackTests.swift new file mode 100644 index 0000000..c22bc32 --- /dev/null +++ b/apple/Tests/UiFeedbackTests.swift @@ -0,0 +1,53 @@ +import XCTest +@testable import VniDrop + +/// Ports `ui/feedback/UiMessageControllerTest.kt` and `UserFacingErrorTest.kt`. +@MainActor +final class UiMessageControllerTests: XCTestCase { + + func testQueuesAndAdvances() { + let c = UiMessageController() + c.show(UiMessage(text: .dynamic("first"))) + c.show(UiMessage(text: .dynamic("second"))) + XCTAssertEqual(c.current?.text, .dynamic("first")) + + c.advance() + XCTAssertEqual(c.current?.text, .dynamic("second")) + + c.advance() + XCTAssertNil(c.current) + } + + func testErrorSuppressesUserCancellation() { + let c = UiMessageController() + c.error(InvitationError.message("QR scanning was cancelled")) + XCTAssertNil(c.current) // cancellations are swallowed + } + + func testErrorShowsNonCancellation() { + let c = UiMessageController() + c.error(InvitationError.message("The transfer was refused")) + XCTAssertEqual(c.current?.tone, .error) + } +} + +@MainActor +final class UserFacingErrorTests: XCTestCase { + + func testIsUserCancellation() { + XCTAssertTrue(InvitationError.message("NFC reading was cancelled").isUserCancellation) + XCTAssertTrue(InvitationError.message("User canceled the picker").isUserCancellation) + XCTAssertFalse(InvitationError.message("A database error occurred").isUserCancellation) + } + + func testToUiTextMapsKnownReasons() { + XCTAssertEqual(InvitationError.message("The transfer was refused").toUiText(), .resource("error_permission")) + XCTAssertEqual(InvitationError.message("invalid ticket").toUiText(), .resource("error_invalid_ticket")) + XCTAssertEqual(InvitationError.message("Select at least one file to share").toUiText(), .resource("error_share_empty")) + XCTAssertEqual(InvitationError.message("Camera access is required").toUiText(), .resource("error_camera")) + } + + func testToUiTextFallsBackToGeneric() { + XCTAssertEqual(InvitationError.message("something entirely unexpected").toUiText(), .resource("error_generic")) + } +} diff --git a/apple/VniDrop/App/AppEnvironment.swift b/apple/VniDrop/App/AppEnvironment.swift new file mode 100644 index 0000000..c89c415 --- /dev/null +++ b/apple/VniDrop/App/AppEnvironment.swift @@ -0,0 +1,32 @@ +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? +} + +@MainActor +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 +} diff --git a/apple/VniDrop/App/AppGraph.swift b/apple/VniDrop/App/AppGraph.swift new file mode 100644 index 0000000..dd029a9 --- /dev/null +++ b/apple/VniDrop/App/AppGraph.swift @@ -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() + } +} diff --git a/apple/VniDrop/App/RootView.swift b/apple/VniDrop/App/RootView.swift new file mode 100644 index 0000000..5a77d2d --- /dev/null +++ b/apple/VniDrop/App/RootView.swift @@ -0,0 +1,189 @@ +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, + repository: graph.coreRepository, + 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 + } + } + // A pending approval is a blocking modal; close the sender's detail panel + // (e.g. the Share/QR sheet) so the approval sheet isn't presented under it + // on macOS. + .onChange(of: approvals.state.current?.id) { _, id in + if id != nil { sendModel.closeDetailPanel() } + } + #if os(macOS) + // macOS keeps `scenePhase == .active` even when the app loses focus, so + // drive foreground/background off NSApplication's active state instead — + // otherwise notifications (only posted when unfocused) never fire. + .onReceive(NotificationCenter.default.publisher(for: NSApplication.didResignActiveNotification)) { _ in + graph.visibility.setForeground(false) + } + .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in + graph.visibility.setForeground(true) + settingsModel.refreshNotificationPermission() + Task { _ = await graph.coreRepository.refresh() } + } + #endif + } + + /// 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 { + Binding( + get: { appModel.destination }, + set: { newValue in + if let value = newValue { + Task { @MainActor in appModel.selectDestination(value) } + } + } + ) + } + + private var destinationBinding: Binding { + // 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 diff --git a/apple/VniDrop/App/VniDropApp.swift b/apple/VniDrop/App/VniDropApp.swift new file mode 100644 index 0000000..a684007 --- /dev/null +++ b/apple/VniDrop/App/VniDropApp.swift @@ -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" + ) + } + } +} diff --git a/apple/VniDrop/Core/AppLogger.swift b/apple/VniDrop/Core/AppLogger.swift new file mode 100644 index 0000000..ceb8463 --- /dev/null +++ b/apple/VniDrop/Core/AppLogger.swift @@ -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: " ") + } +} diff --git a/apple/VniDrop/Core/AppPreferences.swift b/apple/VniDrop/Core/AppPreferences.swift new file mode 100644 index 0000000..74049c0 --- /dev/null +++ b/apple/VniDrop/Core/AppPreferences.swift @@ -0,0 +1,135 @@ +import Foundation +import Combine + +/// Receive-destination descriptor, ported from `core/FileSystemService.kt`. +enum ReceiveFolderKind: String, Codable, Sendable { + case fileSystemPath + case iosSecurityScopedUrl +} + +struct ReceiveFolder: Equatable, Codable, Sendable { + 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 + } +} diff --git a/apple/VniDrop/Core/AppVisibility.swift b/apple/VniDrop/Core/AppVisibility.swift new file mode 100644 index 0000000..25f40b6 --- /dev/null +++ b/apple/VniDrop/Core/AppVisibility.swift @@ -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 + } +} diff --git a/apple/VniDrop/Core/CoreGateway.swift b/apple/VniDrop/Core/CoreGateway.swift new file mode 100644 index 0000000..73d921c --- /dev/null +++ b/apple/VniDrop/Core/CoreGateway.swift @@ -0,0 +1,38 @@ +import Foundation +import Combine +import VnidropCore + +/// Seam between the feature models and the Rust core, mirroring `CoreGateway` +/// in the KMP `shared` module. `CoreRepository` is the production implementation; +/// tests substitute a fake so the models can be exercised without the FFI. +@MainActor +protocol CoreGateway: AnyObject { + /// Latest published core state. + var state: CoreState { get } + /// Publisher of core-state changes (the models subscribe to this). + var statePublisher: AnyPublisher { get } + /// Coalesced change hints emitted by the event sink. + var signals: AnyPublisher { get } + + func initialize(appDataDir: String) async -> Result + func shutdown() + func shareSources( + _ sources: [ShareSource], + transferName: String, + senderName: String, + accessPolicy: ShareAccessPolicy + ) async -> Result + func inspectTicket(_ ticket: String) async -> Result + func receive(ticket: String, outputDir: String, receiverName: String) async -> Result + func receiveIntoSecurityScopedDirectory( + ticket: String, + outputDirectoryUrl: String, + receiverName: String + ) async -> Result + func cancel(transferId: UInt64) async -> Result + func delete(transferId: UInt64) async -> Result + func clearReceiveHistory() async -> Result + func receiverRequests(transferId: UInt64) async -> Result<[ReceiverRequestModel], Error> + func respondReceiverRequest(requestId: String, accepted: Bool, reason: String?) async -> Result + func refresh() async -> Result +} diff --git a/apple/VniDrop/Core/CoreModels.swift b/apple/VniDrop/Core/CoreModels.swift new file mode 100644 index 0000000..33fe647 --- /dev/null +++ b/apple/VniDrop/Core/CoreModels.swift @@ -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, Sendable { + let endpointId: String + let activeTransfers: UInt64 + let activeShares: UInt64 +} + +struct CoreEventModel: Equatable, Identifiable, Sendable { + 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, Sendable { + case requireApproval + case anyoneWithTransfer +} + +enum TransferDirection: Equatable, Sendable { + case send + case receive +} + +enum TransferStatus: Equatable, Sendable { + case importing + case sharing + case receiving + case done + case failed + case cancelled + case stopped +} + +struct Transfer: Equatable, Identifiable, Sendable { + 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, Sendable { + let transferId: UInt64 + let ticket: String + let transferName: String + let contentHash: String + let fileCount: UInt64 + let totalSize: UInt64 +} + +struct TransferMetadataModel: Equatable, Sendable { + let transferId: UInt64 + let transferName: String + let senderName: String? + let contentHash: String + let fileCount: UInt64 + let totalSize: UInt64 +} + +struct TicketInspectionModel: Equatable, Sendable { + let kind: String + let metadata: TransferMetadataModel +} + +enum ReceiverDeliveryStatus: Equatable, Sendable { + case requested + case accepted + case refused + case expired + case completed + case unknown +} + +struct ReceiverRequestModel: Equatable, Identifiable, Sendable { + 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, Sendable { + 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, Sendable { + 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 + } +} diff --git a/apple/VniDrop/Core/CoreRepository.swift b/apple/VniDrop/Core/CoreRepository.swift new file mode 100644 index 0000000..c4895f6 --- /dev/null +++ b/apple/VniDrop/Core/CoreRepository.swift @@ -0,0 +1,396 @@ +import Foundation +import Combine +@preconcurrency 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, CoreGateway { + @Published private(set) var state = CoreState() + var statePublisher: AnyPublisher { $state.eraseToAnyPublisher() } + + private let signalsSubject = PassthroughSubject() + /// Coalesced change hints; subscribe to react to approval/history/transfer changes. + var signals: AnyPublisher { signalsSubject.eraseToAnyPublisher() } + + // Set on the main actor (initialize/shutdown) but read from `queue` inside + // `runCore`; the underlying core is internally synchronized, so this crossing + // is safe. `nonisolated(unsafe)` documents that contract for Swift 6. + private nonisolated(unsafe) 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 nonisolated static let maxEvents = 200 + + // MARK: - Lifecycle + + func initialize(appDataDir: String) async -> Result { + 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 { + 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 { + 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 { + 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 { + 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 { + await runCore { + try self.requireCore().cancelTransfer(transferId: transferId) + }.map { self.refreshSnapshot() } + } + + func delete(transferId: UInt64) async -> Result { + 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 { + 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 { + await runCore { + try self.requireCore().respondReceiverRequest(requestId: requestId, accepted: accepted, reason: reason) + } + } + + func refresh() async -> Result { + // 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: Sendable { + 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 nonisolated func readSnapshot() -> CoreSnapshot? { + guard let core = self.core else { return nil } + let status = core.status() + let transfers = (try? core.listTransfers())?.map { $0.toModel() } ?? [] + let events = (try? core.listEvents(transferId: nil))?.prefix(Self.maxEvents).map { $0.toModel() } ?? [] + 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 nonisolated 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 nonisolated func runCore(_ block: @escaping @Sendable () throws -> T) async -> Result { + await withCheckedContinuation { continuation in + queue.async { + let result: Result + do { + result = .success(try block()) + } catch { + result = .failure(error) + } + continuation.resume(returning: result) + } + } + } + + private nonisolated 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(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 = ["lifecycle", "error", "ticket", "import", "download", "export", "handshake"] +private let refreshKinds: Set = [ + "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 + } + } +} diff --git a/apple/VniDrop/Core/ExternalInvitationController.swift b/apple/VniDrop/Core/ExternalInvitationController.swift new file mode 100644 index 0000000..d0a82cc --- /dev/null +++ b/apple/VniDrop/Core/ExternalInvitationController.swift @@ -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>.Continuation? + lazy var invitations: AsyncStream> = { + 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 { + 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 +} diff --git a/apple/VniDrop/Core/FileSystemService.swift b/apple/VniDrop/Core/FileSystemService.swift new file mode 100644 index 0000000..f50916e --- /dev/null +++ b/apple/VniDrop/Core/FileSystemService.swift @@ -0,0 +1,59 @@ +import Foundation +import VnidropCore + +/// A file/folder selected for sharing, ported from `PickedShareFile` in +/// `core/FilePicker.kt`. +struct PickedShareFile: Equatable, Identifiable, Sendable { + 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. +@MainActor +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 + /// Releases only app-owned picker copies; never deletes original user sources. + func discardPickedFiles(_ files: [PickedShareFile]) async + func sharePickedFiles( + repository: CoreGateway, + files: [PickedShareFile], + transferName: String, + senderName: String, + accessPolicy: ShareAccessPolicy + ) async -> Result +} + +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 { + .failure(InvitationError.message("Revealing the receive folder is not supported")) + } + + func discardPickedFiles(_ files: [PickedShareFile]) async {} +} + +extension ReceiveFolder { + var isFileSystemPath: Bool { kind == .fileSystemPath } +} diff --git a/apple/VniDrop/Core/LocalNotificationService.swift b/apple/VniDrop/Core/LocalNotificationService.swift new file mode 100644 index 0000000..5ab903c --- /dev/null +++ b/apple/VniDrop/Core/LocalNotificationService.swift @@ -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 { + #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 { + 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 diff --git a/apple/VniDrop/Core/TransferProgress.swift b/apple/VniDrop/Core/TransferProgress.swift new file mode 100644 index 0000000..a439335 --- /dev/null +++ b/apple/VniDrop/Core/TransferProgress.swift @@ -0,0 +1,303 @@ +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 + /// Pre-resolved label that overrides `labelKey` when set (e.g. "Sending to 2", + /// which needs a runtime count). + var label: 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 = [ + "import", "ticket", "access", "transfer", "download", "export", + "lifecycle", "network", "handshake", "error", +] + +private let progressKinds: Set = [ + "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) + ) +} + +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 { + var ids = Set() + 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) -> 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 = [",", "}", "]"] + 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..() + + init( + environment: PlatformEnvironment, + repository: CoreGateway, + 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 + } +} diff --git a/apple/VniDrop/Features/Approvals/ApprovalCoordinator.swift b/apple/VniDrop/Features/Approvals/ApprovalCoordinator.swift new file mode 100644 index 0000000..0417987 --- /dev/null +++ b/apple/VniDrop/Features/Approvals/ApprovalCoordinator.swift @@ -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 = [] + + 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: CoreGateway + private let preferences: AppPreferencesRepository + private let notifications: LocalNotificationService + private let visibility: AppVisibility + private let messages: UiMessageController + + private var publishedNotificationIds = Set() + private var cancellables = Set() + + init( + repository: CoreGateway, + 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.statePublisher + .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() + 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 + ) + } +} diff --git a/apple/VniDrop/Features/Approvals/ApprovalModal.swift b/apple/VniDrop/Features/Approvals/ApprovalModal.swift new file mode 100644 index 0000000..e317edf --- /dev/null +++ b/apple/VniDrop/Features/Approvals/ApprovalModal.swift @@ -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) + } +} diff --git a/apple/VniDrop/Features/Receive/ReceiveInvitationActions.swift b/apple/VniDrop/Features/Receive/ReceiveInvitationActions.swift new file mode 100644 index 0000000..06e4db8 --- /dev/null +++ b/apple/VniDrop/Features/Receive/ReceiveInvitationActions.swift @@ -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) -> Void) + func scanQrCode(onResult: @escaping (Result) -> Void) + func readNfcInvitation(onResult: @escaping (Result) -> 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($0) })) + 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) + } +} diff --git a/apple/VniDrop/Features/Receive/ReceiveModel.swift b/apple/VniDrop/Features/Receive/ReceiveModel.swift new file mode 100644 index 0000000..8957007 --- /dev/null +++ b/apple/VniDrop/Features/Receive/ReceiveModel.swift @@ -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: CoreGateway + private let fileSystemService: FileSystemService + private let messages: UiMessageController + private var cancellables = Set() + + init( + repository: CoreGateway, + fileSystemService: FileSystemService, + preferences: AppPreferencesRepository, + messages: UiMessageController + ) { + self.repository = repository + self.fileSystemService = fileSystemService + self.messages = messages + + repository.statePublisher.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 + 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) { + 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 + 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 ? { 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: { 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 + } +} diff --git a/apple/VniDrop/Features/Receive/ReceiveScreen.swift b/apple/VniDrop/Features/Receive/ReceiveScreen.swift new file mode 100644 index 0000000..9bd50e8 --- /dev/null +++ b/apple/VniDrop/Features/Receive/ReceiveScreen.swift @@ -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 { + 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 { + ContentUnavailableView { + Label(String(localized: "receive_empty_title"), systemImage: "tray.and.arrow.down") + } description: { + Text(LocalizedStringKey("receive_empty_body")) + } actions: { + 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) + } + } +} diff --git a/apple/VniDrop/Features/Send/FilePreviewRepository.swift b/apple/VniDrop/Features/Send/FilePreviewRepository.swift new file mode 100644 index 0000000..8c6333b --- /dev/null +++ b/apple/VniDrop/Features/Send/FilePreviewRepository.swift @@ -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) { + 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 + } +} diff --git a/apple/VniDrop/Features/Send/SendModel.swift b/apple/VniDrop/Features/Send/SendModel.swift new file mode 100644 index 0000000..53aa931 --- /dev/null +++ b/apple/VniDrop/Features/Send/SendModel.swift @@ -0,0 +1,351 @@ +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 + + /// Receiver delivery records per active (sharing) transfer, used to decide + /// which transfers still have an in-flight receiver. Delivery status is the + /// authoritative signal; byte-transfer events alone don't reliably mark a + /// small transfer complete. + @Published private(set) var receiversByTransfer: [UInt64: [ReceiverRequestModel]] = [:] + + private let repository: CoreGateway + private let fileSystemService: FileSystemService + private let filePreviewRepository: FilePreviewRepository + private let messages: UiMessageController + private var cancellables = Set() + + init( + repository: CoreGateway, + fileSystemService: FileSystemService, + preferences: AppPreferencesRepository, + filePreviewRepository: FilePreviewRepository, + messages: UiMessageController + ) { + self.repository = repository + self.fileSystemService = fileSystemService + self.filePreviewRepository = filePreviewRepository + self.messages = messages + + repository.statePublisher.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) } + self.refreshReceiverStatuses(for: id) + } + } + .store(in: &cancellables) + + // Keep receiver delivery records current for every sharing/importing + // outgoing transfer (new shares appear here; status transitions arrive via + // the receiverHistoryChanged signal above). + repository.statePublisher + .map { core -> Set in + Set(core.transfers + .filter { $0.direction == .send && ($0.status == .sharing || $0.status == .importing) } + .map(\.transferId)) + } + .removeDuplicates() + .sink { [weak self] ids in self?.syncSharingReceivers(ids) } + .store(in: &cancellables) + + filePreviewRepository.$previews + .sink { [weak self] previews in self?.state.transferThumbnails = previews } + .store(in: &cancellables) + + repository.statePublisher + .map { core -> Set? 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) + } + } + } + + /// Cancels/refuses a single receiver by responding to its request negatively. + /// Uses the core's `respondReceiverRequest` (no backend change); applies to + /// receivers that are still pending or accepted. + func cancelReceiver(requestId: String) { + Task { + let result = await repository.respondReceiverRequest(requestId: requestId, accepted: false, reason: nil) + switch result { + case .success: + if let transferId = state.selectedTransferId { refreshReceivers(transferId) } + _ = await repository.refresh() + case .failure(let error): + messages.error(error) + } + } + } + + /// Stops an active outgoing share (interrupts any in-flight receivers). The + /// transfer stays in history as "Stopped". Uses the core's `cancelTransfer`. + func stopSharing(transferId: UInt64) { + Task { + let result = await repository.cancel(transferId: transferId) + switch result { + case .success: + _ = await repository.refresh() + messages.tryShow(UiMessage(text: .resource("transfer_event_stopped"), tone: .info)) + case .failure(let error): + messages.error(error) + } + } + } + + // MARK: - Invitation results / share + + func onInvitationResult(_ action: InvitationAction, _ result: Result) { + 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) } + } + + /// Refresh the receiver records for the sharing set, pruning transfers that are + /// no longer active. + private func syncSharingReceivers(_ ids: Set) { + receiversByTransfer = receiversByTransfer.filter { ids.contains($0.key) } + for id in ids { refreshReceiverStatuses(for: id) } + } + + private func refreshReceiverStatuses(for transferId: UInt64) { + Task { + if case .success(let requests) = await repository.receiverRequests(transferId: transferId) { + receiversByTransfer[transferId] = requests + } + } + } + + 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) + } + } + } +} diff --git a/apple/VniDrop/Features/Send/SendScreen.swift b/apple/VniDrop/Features/Send/SendScreen.swift new file mode 100644 index 0000000..368c219 --- /dev/null +++ b/apple/VniDrop/Features/Send/SendScreen.swift @@ -0,0 +1,211 @@ +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 { + Binding(get: { model.state.selectedTransferId != nil }, set: { if !$0 { model.closeTransferDetails() } }) + } + + var body: some View { + NavigationStack { + Group { + if outgoing.isEmpty { + 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 { + ContentUnavailableView { + Label(String(localized: "send_empty_title"), systemImage: "paperplane") + } description: { + Text(LocalizedStringKey("send_empty_body")) + } actions: { + 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 sharingProgress(for: transfer) + default: return nil + } + } + + /// Progress for an active share, driven by receivers whose delivery is still + /// in flight (`.accepted`). Returns nil when none are downloading, so the bar + /// clears once every receiver has completed even if byte events lag. + private func sharingProgress(for transfer: Transfer) -> TransferProgress? { + let active = (model.receiversByTransfer[transfer.transferId] ?? []).filter { $0.status == .accepted } + if active.isEmpty { return nil } + let fractions = active.compactMap { + progressForReceiver(events: model.coreState.events, transferId: transfer.transferId, + remoteEndpointId: $0.remoteEndpointId, totalSizeHint: transfer.totalSize)?.progress + } + let combined = fractions.isEmpty ? nil : fractions.reduce(0, +) / Double(fractions.count) + if active.count == 1 { + return TransferProgress(transferId: transfer.transferId, phase: "transfer", kind: "progress", + labelKey: "progress_sending", progress: combined) + } + return TransferProgress(transferId: transfer.transferId, phase: "transfer", kind: "progress", + labelKey: "progress_sending", progress: combined, + label: String(format: String(localized: "progress_sending_to_count"), active.count)) + } +} + +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, labelText: progress.label) + .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 + } + } +} diff --git a/apple/VniDrop/Features/Send/TransferComposer.swift b/apple/VniDrop/Features/Send/TransferComposer.swift new file mode 100644 index 0000000..6404bf8 --- /dev/null +++ b/apple/VniDrop/Features/Send/TransferComposer.swift @@ -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($0) })) + Field(label: String(localized: "field_sender_name"), + value: Binding(get: { state.senderName }, set: { model.setSenderName($0) })) + 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) + } +} diff --git a/apple/VniDrop/Features/Send/TransferDetailsView.swift b/apple/VniDrop/Features/Send/TransferDetailsView.swift new file mode 100644 index 0000000..652bb72 --- /dev/null +++ b/apple/VniDrop/Features/Send/TransferDetailsView.swift @@ -0,0 +1,378 @@ +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] + @State private var showStopConfirmation = false + + private var isActiveShare: Bool { + transfer.status == .sharing || transfer.status == .importing + } + + 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 + ) + } + + if isActiveShare { + Section { + Button(role: .destructive) { + showStopConfirmation = true + } label: { + Label(String(localized: "send_stop_sharing"), systemImage: "stop.circle") + } + } + } + } + .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") + } + } + } + .confirmationDialog( + Text(LocalizedStringKey("send_stop_sharing")), + isPresented: $showStopConfirmation, + titleVisibility: .visible + ) { + Button(String(localized: "send_stop_sharing"), role: .destructive) { + model.stopSharing(transferId: transfer.transferId) + } + Button(String(localized: "button_cancel"), role: .cancel) {} + } message: { + Text(LocalizedStringKey("send_stop_sharing_description")) + } + } +} + +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, + onCancel: model.cancelReceiver + ) + case .share: + TransferSharePanel(model: model, transfer: transfer) + } + } +} + +private struct PanelContainer: 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 + let onCancel: (String) -> Void + + 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), onCancel: onCancel) + } + } + } + } + + 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? + let onCancel: (String) -> Void + + /// Only pending requests can be cancelled per-receiver: the core rejects a + /// negative response to an already-accepted request ("...not approved, or it + /// was refused"). Interrupting an in-flight receiver needs Stop sharing. + private var isCancelable: Bool { + receiver.status == .requested + } + + 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 + HStack(alignment: .top, spacing: 12) { + 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, labelText: sendProgress.label) + } 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) + if isCancelable { + Button(role: .destructive) { + onCancel(receiver.id) + } label: { + Text(LocalizedStringKey("button_refuse")) + .font(VniType.bodySmall) + } + .buttonStyle(.borderless) + .tint(.red) + } + } + .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 diff --git a/apple/VniDrop/Features/Send/TransferShareActions.swift b/apple/VniDrop/Features/Send/TransferShareActions.swift new file mode 100644 index 0000000..02b93f5 --- /dev/null +++ b/apple/VniDrop/Features/Send/TransferShareActions.swift @@ -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) + func shareInvitation(ticket: String, transferName: String, onResult: @escaping (Result) -> Void) + func writeInvitationToNfc(ticket: String, onResult: @escaping (Result) -> 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() } + } +} diff --git a/apple/VniDrop/Features/Settings/BugReportService.swift b/apple/VniDrop/Features/Settings/BugReportService.swift new file mode 100644 index 0000000..5bdfad9 --- /dev/null +++ b/apple/VniDrop/Features/Settings/BugReportService.swift @@ -0,0 +1,31 @@ +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. +@MainActor +protocol BugReportService { + func submit(_ draft: BugReportDraft, deviceInfo: DeviceInfo?) async -> Result + 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 { + .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 +} diff --git a/apple/VniDrop/Features/Settings/SettingsModel.swift b/apple/VniDrop/Features/Settings/SettingsModel.swift new file mode 100644 index 0000000..f237e4f --- /dev/null +++ b/apple/VniDrop/Features/Settings/SettingsModel.swift @@ -0,0 +1,360 @@ +import Foundation +import Combine + +/// Settings sections, ported from `feature/settings/SettingsViewModel.kt`. +enum SettingsSection: Hashable { + case overview + case preferences + case appearance + case notifications + case storage + 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 .storage: return "storage_title" + case .about: return "about_title" + case .bugReport: return "about_bug_report" + } + } +} + +/// On-disk usage breakdown for the Storage screen. +struct StorageBreakdown: Equatable { + var receivedFiles: UInt64 = 0 + var transferData: UInt64 = 0 + var temporary: UInt64 = 0 + var total: UInt64 { receivedFiles + transferData + temporary } +} + +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 + var storage: StorageBreakdown? + var isCalculatingStorage = false + var isDeletingTransfers = false + + 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.storage == rhs.storage && lhs.isCalculatingStorage == rhs.isCalculatingStorage + && lhs.isDeletingTransfers == rhs.isDeletingTransfers + && 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 repository: CoreGateway + 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? + private var hasLocalUsernameDraft = false + private var cancellables = Set() + + init( + environment: PlatformEnvironment, + deviceInfoProvider: DeviceInfoProvider, + fileSystemService: FileSystemService, + repository: CoreGateway, + preferences: AppPreferencesRepository, + notifications: LocalNotificationService, + messages: UiMessageController, + bugReports: BugReportService, + diagnosticsIncluded: Bool = DiagnosticsBuildConfig.included + ) { + self.environment = environment + self.deviceInfoProvider = deviceInfoProvider + self.fileSystemService = fileSystemService + self.repository = repository + 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 ? { 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(onSuccess: @escaping () -> Void = {}) { + 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)) + onSuccess() + 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)) + } + + // MARK: - Storage + + /// Recomputes the on-disk usage breakdown off the main actor. + func loadStorageUsage() { + if state.isCalculatingStorage { return } + state.isCalculatingStorage = true + let coreDir = environment.defaultCoreDataDir + let receiveDir = state.receiveFolder?.isFileSystemPath == true ? state.receiveFolder?.value : nil + let tempDir = NSTemporaryDirectory() + Task.detached { + let breakdown = StorageBreakdown( + receivedFiles: receiveDir.map { SettingsModel.directorySize($0) } ?? 0, + transferData: SettingsModel.directorySize(coreDir), + temporary: SettingsModel.directorySize(tempDir) + ) + await MainActor.run { [weak self] in + self?.state.storage = breakdown + self?.state.isCalculatingStorage = false + } + } + } + + /// Deletes every send/receive transfer via the core, freeing the imported + /// shared-file content and clearing history. Node identity and received files + /// are left untouched. + func deleteAllTransfers() { + if state.isDeletingTransfers { return } + state.isDeletingTransfers = true + Task { + for id in repository.state.transfers.map(\.transferId) { + _ = await repository.delete(transferId: id) + } + _ = await repository.refresh() + state.isDeletingTransfers = false + loadStorageUsage() + messages.show(UiMessage(text: .resource("storage_transfers_deleted"), tone: .success)) + } + } + + /// Recursive size of every regular file under `path` (0 if missing). + nonisolated static func directorySize(_ path: String) -> UInt64 { + let url = URL(fileURLWithPath: path) + guard let enumerator = FileManager.default.enumerator( + at: url, includingPropertiesForKeys: [.isRegularFileKey, .totalFileAllocatedSizeKey, .fileSizeKey] + ) else { return 0 } + var total: UInt64 = 0 + for case let fileURL as URL in enumerator { + let values = try? fileURL.resourceValues(forKeys: [.isRegularFileKey, .totalFileAllocatedSizeKey, .fileSizeKey]) + guard values?.isRegularFile == true else { continue } + total += UInt64(values?.totalFileAllocatedSize ?? values?.fileSize ?? 0) + } + return total + } + + 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 + } +} diff --git a/apple/VniDrop/Features/Settings/SettingsScreen.swift b/apple/VniDrop/Features/Settings/SettingsScreen.swift new file mode 100644 index 0000000..a83d210 --- /dev/null +++ b/apple/VniDrop/Features/Settings/SettingsScreen.swift @@ -0,0 +1,137 @@ +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 + @State private var showBugReport = false + + 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.storage) { + SettingsRow(icon: "internaldrive", title: String(localized: "storage_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 + sectionForm(section) + } + } + } + + @ViewBuilder + private func sectionForm(_ section: SettingsSection) -> some View { + let content = Form { + SettingsSectionContent(model: model, section: section) + } + .formStyle(.grouped) + .navigationTitle(Text(LocalizedStringKey(section.titleKey))) + + if section == .about { + content + #if os(iOS) + .navigationBarTitleDisplayMode(.inline) + #endif + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button { + showBugReport = true + } label: { + Label(String(localized: "about_bug_report"), systemImage: "ladybug") + } + } + } + .sheet(isPresented: $showBugReport) { + BugReportSheet(model: model) + } + } else { + content + #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 .storage: + StorageSettings(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") + } +} diff --git a/apple/VniDrop/Features/Settings/SettingsSections.swift b/apple/VniDrop/Features/Settings/SettingsSections.swift new file mode 100644 index 0000000..b4f03f8 --- /dev/null +++ b/apple/VniDrop/Features/Settings/SettingsSections.swift @@ -0,0 +1,264 @@ +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($0) })) + } + 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 StorageSettings: View { + @ObservedObject var model: SettingsModel + @State private var showDeleteConfirmation = false + + var body: some View { + Section { + if let storage = model.state.storage { + LabeledContent(String(localized: "storage_received_files"), value: formatBytes(storage.receivedFiles)) + LabeledContent(String(localized: "storage_transfer_data"), value: formatBytes(storage.transferData)) + LabeledContent(String(localized: "storage_temporary"), value: formatBytes(storage.temporary)) + LabeledContent(String(localized: "storage_total")) { + Text(formatBytes(storage.total)).fontWeight(.semibold) + } + } else { + HStack { + Text(LocalizedStringKey("storage_calculating")).foregroundStyle(.secondary) + Spacer() + ProgressView() + } + } + } footer: { + Text(LocalizedStringKey("storage_footer")) + } + + Section { + Button(role: .destructive) { + showDeleteConfirmation = true + } label: { + HStack { + Text(model.state.isDeletingTransfers + ? String(localized: "storage_deleting") + : String(localized: "storage_delete_transfers")) + if model.state.isDeletingTransfers { + Spacer() + ProgressView() + } + } + } + .disabled(model.state.isDeletingTransfers) + } + .onAppear { model.loadStorageUsage() } + .confirmationDialog( + Text(LocalizedStringKey("storage_delete_transfers")), + isPresented: $showDeleteConfirmation, + titleVisibility: .visible + ) { + Button(String(localized: "storage_delete_transfers"), role: .destructive) { + model.deleteAllTransfers() + } + Button(String(localized: "button_cancel"), role: .cancel) {} + } message: { + Text(LocalizedStringKey("storage_delete_transfers_description")) + } + } +} + +struct AboutSettings: View { + @ObservedObject var model: SettingsModel + + private static let privacyPolicyURL = URL(string: "https://github.com/vnidrop/vnidrop")! + + var body: some View { + Section { + Text(LocalizedStringKey("about_tagline")).font(.headline) + Text(LocalizedStringKey("about_description")).foregroundStyle(.secondary) + } + + Section(String(localized: "about_is_title")) { + AboutPoint("about_is_direct", "paperplane") + AboutPoint("about_is_no_account", "person.crop.circle.badge.xmark") + AboutPoint("about_is_in_control", "checkmark.shield") + AboutPoint("about_is_encrypted", "lock") + AboutPoint("about_is_open", "chevron.left.forwardslash.chevron.right") + } + + Section(String(localized: "about_isnt_title")) { + AboutPoint("about_isnt_cloud", "icloud.slash") + AboutPoint("about_isnt_sync", "arrow.triangle.2.circlepath") + AboutPoint("about_isnt_public", "megaphone") + } + + Section(String(localized: "about_privacy_title")) { + AboutPoint("about_privacy_capability", "qrcode") + AboutPoint("about_privacy_deny", "hand.raised") + AboutPoint("about_privacy_relay", "antenna.radiowaves.left.and.right") + AboutPoint("about_privacy_local", "internaldrive") + } + + 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) + } + LabeledContent(String(localized: "about_license_label"), value: "Apache 2.0") + Link(destination: Self.privacyPolicyURL) { + Label(String(localized: "about_privacy_policy_label"), systemImage: "hand.raised") + } + } + + if DiagnosticsBuildConfig.included { + Section { + Toggle(isOn: Binding( + get: { model.state.diagnosticsEnabled }, + set: { model.setDiagnosticsEnabled($0) } + )) { + Text(LocalizedStringKey("diagnostics_title")) + } + } + } + } +} + +/// Bug report presented as a sheet from About. Can be dismissed by swipe only +/// when empty; otherwise the Cancel button is required. +struct BugReportSheet: View { + @ObservedObject var model: SettingsModel + @Environment(\.dismiss) private var dismiss + + private var isEmpty: Bool { + model.state.bugWhatHappened.isEmpty && model.state.bugExpected.isEmpty + && model.state.bugSteps.isEmpty && model.state.bugContact.isEmpty + } + + var body: some View { + NavigationStack { + Form { + BugReportSettings(model: model, onSubmitted: { dismiss() }) + } + .formStyle(.grouped) + .navigationTitle(Text(LocalizedStringKey("about_bug_report"))) + #if os(iOS) + .navigationBarTitleDisplayMode(.inline) + #endif + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(String(localized: "button_cancel")) { dismiss() } + } + } + } + .interactiveDismissDisabled(!isEmpty) + } +} + +/// A bullet-style informational row with an SF Symbol and wrapping localized text. +private struct AboutPoint: View { + let key: String + let symbol: String + + init(_ key: String, _ symbol: String) { + self.key = key + self.symbol = symbol + } + + var body: some View { + Label { + Text(LocalizedStringKey(key)) + .font(.subheadline) + .fixedSize(horizontal: false, vertical: true) + } icon: { + Image(systemName: symbol).foregroundStyle(.tint) + } + } +} + +struct BugReportSettings: View { + @ObservedObject var model: SettingsModel + var onSubmitted: () -> Void = {} + + var body: some View { + Section(String(localized: "bug_report_what_label")) { + TextField("", text: Binding(get: { model.state.bugWhatHappened }, set: { model.setBugWhatHappened($0) }), + prompt: Text(LocalizedStringKey("bug_report_what_hint")), axis: .vertical) + .lineLimit(3, reservesSpace: true) + .labelsHidden() + } + Section(String(localized: "bug_report_expected_label")) { + TextField("", text: Binding(get: { model.state.bugExpected }, set: { model.setBugExpected($0) }), + prompt: Text(LocalizedStringKey("bug_report_expected_hint")), axis: .vertical) + .lineLimit(3, reservesSpace: true) + .labelsHidden() + } + Section(String(localized: "bug_report_steps_label")) { + TextField("", text: Binding(get: { model.state.bugSteps }, set: { model.setBugSteps($0) }), + prompt: Text(LocalizedStringKey("bug_report_steps_hint")), axis: .vertical) + .lineLimit(3, reservesSpace: true) + .labelsHidden() + } + Section(String(localized: "bug_report_contact_label")) { + TextField("", text: Binding(get: { model.state.bugContact }, set: { model.setBugContact($0) }), + prompt: Text(LocalizedStringKey("bug_report_contact_hint"))) + .labelsHidden() + } + Section { + Toggle(isOn: Binding(get: { model.state.bugIncludeLogs }, set: { model.setBugIncludeLogs($0) })) { + Text(LocalizedStringKey("bug_report_include_logs")) + } + Button(action: { model.submitBugReport(onSuccess: onSubmitted) }) { + Text(model.state.isSubmittingBugReport + ? String(localized: "bug_report_submitting") : String(localized: "bug_report_submit")) + } + .disabled(model.state.isSubmittingBugReport) + } + } +} diff --git a/apple/VniDrop/Platform/AppDependencies+iOS.swift b/apple/VniDrop/Platform/AppDependencies+iOS.swift new file mode 100644 index 0000000..1c27c43 --- /dev/null +++ b/apple/VniDrop/Platform/AppDependencies+iOS.swift @@ -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 diff --git a/apple/VniDrop/Platform/AppDependencies+macOS.swift b/apple/VniDrop/Platform/AppDependencies+macOS.swift new file mode 100644 index 0000000..d75aa46 --- /dev/null +++ b/apple/VniDrop/Platform/AppDependencies+macOS.swift @@ -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 diff --git a/apple/VniDrop/Platform/FileSystemService+iOS.swift b/apple/VniDrop/Platform/FileSystemService+iOS.swift new file mode 100644 index 0000000..e9f4a37 --- /dev/null +++ b/apple/VniDrop/Platform/FileSystemService+iOS.swift @@ -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 { + 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: CoreGateway, + files: [PickedShareFile], + transferName: String, + senderName: String, + accessPolicy: ShareAccessPolicy + ) async -> Result { + 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 diff --git a/apple/VniDrop/Platform/FileSystemService+macOS.swift b/apple/VniDrop/Platform/FileSystemService+macOS.swift new file mode 100644 index 0000000..55628ec --- /dev/null +++ b/apple/VniDrop/Platform/FileSystemService+macOS.swift @@ -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 { + 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: CoreGateway, + files: [PickedShareFile], + transferName: String, + senderName: String, + accessPolicy: ShareAccessPolicy + ) async -> Result { + 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 diff --git a/apple/VniDrop/Platform/InvitationFile.swift b/apple/VniDrop/Platform/InvitationFile.swift new file mode 100644 index 0000000..25a77cb --- /dev/null +++ b/apple/VniDrop/Platform/InvitationFile.swift @@ -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 +} diff --git a/apple/VniDrop/Platform/PlatformPickers.swift b/apple/VniDrop/Platform/PlatformPickers.swift new file mode 100644 index 0000000..7d70465 --- /dev/null +++ b/apple/VniDrop/Platform/PlatformPickers.swift @@ -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)) + } +} diff --git a/apple/VniDrop/Platform/ReceiveInvitationActions+iOS.swift b/apple/VniDrop/Platform/ReceiveInvitationActions+iOS.swift new file mode 100644 index 0000000..ba0ba3f --- /dev/null +++ b/apple/VniDrop/Platform/ReceiveInvitationActions+iOS.swift @@ -0,0 +1,268 @@ +#if os(iOS) +import UIKit +@preconcurrency import AVFoundation +@preconcurrency 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) -> 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) -> 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) -> 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) -> 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: + // The permission callback is delivered back on the main queue. + nonisolated(unsafe) let completion = completion + 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) -> Void + private let session = AVCaptureSession() + private var previewLayer: AVCaptureVideoPreviewLayer? + private var finished = false + + init(onResult: @escaping (Result) -> 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() } + } + + // The metadata output delegate queue is `.main`, so hop back onto the main + // actor to touch view-controller state. + nonisolated 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) ?? "" + guard !value.isEmpty else { return } + MainActor.assumeIsolated { finish(.success(value)) } + } + + private func finish(_ result: Result) { + 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`. +// Runs entirely on the NFC session's `.main` delegate queue. +final class InvitationNfcReader: NSObject, NFCNDEFReaderSessionDelegate, @unchecked Sendable { + private let onResult: (Result) -> Void + private var session: NFCNDEFReaderSession? + private var finished = false + + init(onResult: @escaping (Result) -> 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 { + 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) { + 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 diff --git a/apple/VniDrop/Platform/ReceiveInvitationActions+macOS.swift b/apple/VniDrop/Platform/ReceiveInvitationActions+macOS.swift new file mode 100644 index 0000000..2586941 --- /dev/null +++ b/apple/VniDrop/Platform/ReceiveInvitationActions+macOS.swift @@ -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) -> 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) -> Void) { + onResult(.failure(InvitationError.message("QR scanning is unavailable on macOS"))) + } + + func readNfcInvitation(onResult: @escaping (Result) -> Void) { + onResult(.failure(InvitationError.message("NFC is unavailable on macOS"))) + } + + func cancel() {} +} +#endif diff --git a/apple/VniDrop/Platform/TransferShareActions+iOS.swift b/apple/VniDrop/Platform/TransferShareActions+iOS.swift new file mode 100644 index 0000000..2f4229c --- /dev/null +++ b/apple/VniDrop/Platform/TransferShareActions+iOS.swift @@ -0,0 +1,159 @@ +#if os(iOS) +import UIKit +@preconcurrency 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) { + 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) { + 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) { + 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`. +// Runs entirely on the NFC session's `.main` delegate queue. +final class InvitationNfcWriter: NSObject, NFCNDEFReaderSessionDelegate, @unchecked Sendable { + private let ticket: String + private let onResult: (Result) -> Void + private var session: NFCNDEFReaderSession? + private var finished = false + + init(ticket: String, onResult: @escaping (Result) -> 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 firstTag = tags.first else { + return finish(.failure(InvitationError.message("No NFC tag was detected"))) + } + // CoreNFC completion handlers run on the session's `.main` queue; these + // framework values are safe to use there. + nonisolated(unsafe) let session = session + nonisolated(unsafe) let tag = firstTag + 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) { + 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 diff --git a/apple/VniDrop/Platform/TransferShareActions+macOS.swift b/apple/VniDrop/Platform/TransferShareActions+macOS.swift new file mode 100644 index 0000000..20878b9 --- /dev/null +++ b/apple/VniDrop/Platform/TransferShareActions+macOS.swift @@ -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) { + 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) { + 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) { + onResult(.failure(InvitationError.message("NFC is unavailable on macOS"))) + } + + func cancelNfcWrite() {} +} +#endif diff --git a/apple/VniDrop/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json b/apple/VniDrop/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..bf7455d --- /dev/null +++ b/apple/VniDrop/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -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 } +} diff --git a/apple/VniDrop/Resources/Assets.xcassets/AppIcon.appiconset/app-icon.png b/apple/VniDrop/Resources/Assets.xcassets/AppIcon.appiconset/app-icon.png new file mode 100644 index 0000000..91e521f Binary files /dev/null and b/apple/VniDrop/Resources/Assets.xcassets/AppIcon.appiconset/app-icon.png differ diff --git a/apple/VniDrop/Resources/Assets.xcassets/Contents.json b/apple/VniDrop/Resources/Assets.xcassets/Contents.json new file mode 100644 index 0000000..46cba7d --- /dev/null +++ b/apple/VniDrop/Resources/Assets.xcassets/Contents.json @@ -0,0 +1 @@ +{ "info" : { "author" : "xcode", "version" : 1 } } diff --git a/apple/VniDrop/Resources/Info.plist b/apple/VniDrop/Resources/Info.plist new file mode 100644 index 0000000..f19dd0c --- /dev/null +++ b/apple/VniDrop/Resources/Info.plist @@ -0,0 +1,100 @@ + + + + + CADisableMinimumFrameDurationOnPhone + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + VniDrop + CFBundleDocumentTypes + + + CFBundleTypeName + VniDrop Invitation + CFBundleTypeRole + Viewer + LSHandlerRank + Owner + LSItemContentTypes + + com.vnidrop.app.invitation + + + + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + LSApplicationCategoryType + public.app-category.utilities + LSSupportsOpeningDocumentsInPlace + + NFCReaderUsageDescription + VniDrop uses NFC to read transfer invitation tags. + NSBonjourServices + + + + NSCameraUsageDescription + VniDrop uses the camera to scan transfer QR codes. + NSLocalNetworkUsageDescription + VniDrop needs local network access to send to other local devices if needed. + UIBackgroundModes + + fetch + processing + remote-notification + + UIFileSharingEnabled + + UILaunchScreen + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UIViewControllerBasedStatusBarAppearance + + UTExportedTypeDeclarations + + + UTTypeConformsTo + + public.data + + UTTypeDescription + VniDrop Invitation + UTTypeIdentifier + com.vnidrop.app.invitation + UTTypeTagSpecification + + public.filename-extension + + vnd + + public.mime-type + application/vnd.vnidrop.transfer + + + + + diff --git a/apple/VniDrop/Resources/Localizable.xcstrings b/apple/VniDrop/Resources/Localizable.xcstrings new file mode 100644 index 0000000..b34e4e2 --- /dev/null +++ b/apple/VniDrop/Resources/Localizable.xcstrings @@ -0,0 +1,2578 @@ +{ + "sourceLanguage" : "en", + "strings" : { + "%@" : { + + }, + "%@ · %@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$@ · %2$@" + } + } + } + }, + "%@ %@ · %@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$@ %2$@ · %3$@" + } + } + } + }, + "%@%%" : { + + }, + "about_bug_report" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Report a bug" + } + } + } + }, + "about_description" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "VniDrop moves files and folders straight from one device to another over the network, without uploading them to any file-hosting service. There’s no account to create, and no copy of your transfer is left in the cloud once you’re done." + } + } + } + }, + "about_is_title" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "What VniDrop is" + } + } + } + }, + "about_is_direct" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "A direct device-to-device transfer — your files go straight to the receiver." + } + } + } + }, + "about_is_no_account" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Account-free — there’s nothing to sign up for." + } + } + } + }, + "about_is_in_control" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "You decide who receives: approve each request, or open a transfer to anyone holding the invitation." + } + } + } + }, + "about_is_encrypted" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Connections are authenticated and end-to-end encrypted (via Iroh), and incoming files are verified by their content hash." + } + } + } + }, + "about_is_open" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Open source, released under the Apache 2.0 license." + } + } + } + }, + "about_isnt_title" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "What VniDrop isn’t" + } + } + } + }, + "about_isnt_cloud" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Not cloud storage — no server holds your files, and nothing waits in the cloud after a transfer." + } + } + } + }, + "about_isnt_sync" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Not a sync or backup service." + } + } + } + }, + "about_isnt_public" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Not public broadcasting — an invitation is a private access link, not an announcement to everyone nearby." + } + } + } + }, + "about_privacy_title" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Privacy & security" + } + } + } + }, + "about_privacy_capability" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Invitations are capabilities. Treat a QR code, NFC tag, or .vnd file like a private access link and share it only with the people you intend — especially with “Anyone with this transfer.”" + } + } + } + }, + "about_privacy_deny" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Deny by default — VniDrop serves only the content of an active share and rejects unknown requests." + } + } + } + }, + "about_privacy_relay" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "If two devices can’t connect directly, the encrypted connection may be relayed. Relays forward encrypted packets only; they never store your files." + } + } + } + }, + "about_privacy_local" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Received files are saved on your device and never silently overwrite an existing file." + } + } + } + }, + "about_license_label" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "License" + } + } + } + }, + "about_privacy_policy_label" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Privacy policy" + } + } + } + }, + "about_tagline" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Send files directly. Stay in control of who receives them." + } + } + } + }, + "about_privacy" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Privacy policy" + } + } + } + }, + "about_title" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "About" + } + } + } + }, + "appearance_auto_description" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Match this device’s light or dark appearance." + } + } + } + }, + "appearance_dark_mode" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dark mode" + } + } + } + }, + "appearance_light_mode" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Light mode" + } + } + } + }, + "appearance_system_mode" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "System" + } + } + } + }, + "appearance_title" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Appearance" + } + } + } + }, + "approval_connection_request" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Receive request" + } + } + } + }, + "approval_endpoint_id" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Device ID: %1$@" + } + } + } + }, + "approval_nearby_device" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "A nearby device" + } + } + } + }, + "approval_pending_count" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$d requests waiting" + } + } + } + }, + "approval_request_body" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ wants to receive “%2$@”." + } + } + } + }, + "battery_level_title" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Battery level" + } + } + } + }, + "bug_report_contact_hint" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "name@example.com" + } + } + } + }, + "bug_report_contact_label" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Contact email (optional)" + } + } + } + }, + "bug_report_expected_hint" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Describe what you expected to happen" + } + } + } + }, + "bug_report_steps_hint" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "List the steps to reproduce the issue" + } + } + } + }, + "bug_report_what_hint" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Describe what happened" + } + } + } + }, + "bug_report_description" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tell us what went wrong. We attach device info and optional recent logs (with sensitive values redacted)." + } + } + } + }, + "bug_report_device_section" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Device information" + } + } + } + }, + "bug_report_expected_label" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "What did you expect?" + } + } + } + }, + "bug_report_include_logs" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Include recent logs" + } + } + } + }, + "bug_report_include_logs_description" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Helps us diagnose the issue. Sensitive values are redacted before sending." + } + } + } + }, + "bug_report_logs_size" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Log attachment size" + } + } + } + }, + "bug_report_missing_expected" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Please describe what you expected." + } + } + } + }, + "bug_report_missing_what" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Please describe what happened." + } + } + } + }, + "bug_report_steps_label" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Steps to reproduce (optional)" + } + } + } + }, + "bug_report_submit" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Submit report" + } + } + } + }, + "bug_report_submit_failed" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Could not submit the bug report. Try again later." + } + } + } + }, + "bug_report_submitted" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Thanks — your bug report was recorded." + } + } + } + }, + "bug_report_submitting" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Submitting…" + } + } + } + }, + "bug_report_what_label" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "What happened?" + } + } + } + }, + "button_approve" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Approve" + } + } + } + }, + "button_back" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Back" + } + } + } + }, + "button_cancel" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cancel" + } + } + } + }, + "button_cancel_receive" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cancel" + } + } + } + }, + "button_change_files" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Change files" + } + } + } + }, + "button_choose_files" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Choose files" + } + } + } + }, + "button_choose_folder" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Choose folder" + } + } + } + }, + "button_clear" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Clear" + } + } + } + }, + "button_close" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Close" + } + } + } + }, + "button_create_new_transfer" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "New transfer" + } + } + } + }, + "button_delete_transfer" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Delete transfer" + } + } + } + }, + "button_download_invitation" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Save .vnd file" + } + } + } + }, + "button_native_share" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Share invitation" + } + } + } + }, + "button_open_settings" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Open Settings" + } + } + } + }, + "button_receive" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Receive" + } + } + } + }, + "button_receive_files" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Start receiving" + } + } + } + }, + "button_refuse" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Refuse" + } + } + } + }, + "button_remove_file" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Remove file" + } + } + } + }, + "button_reset_default" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Use default" + } + } + } + }, + "button_retry" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Retry" + } + } + } + }, + "button_share_file" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Start sharing" + } + } + } + }, + "button_sharing_file" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Preparing transfer…" + } + } + } + }, + "button_show_in_files" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Show in Files" + } + } + } + }, + "button_write_nfc" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Write to NFC tag" + } + } + } + }, + "device_model_title" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Device model" + } + } + } + }, + "device_name_title" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Device name" + } + } + } + }, + "diagnostics_description" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Send anonymous crash reports and usage events so we can improve VniDrop. You can turn this off anytime. Invitations, file paths, and transfer contents are never included." + } + } + } + }, + "diagnostics_disabled_message" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Diagnostics sharing is off." + } + } + } + }, + "diagnostics_enabled_message" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Diagnostics sharing is on." + } + } + } + }, + "diagnostics_title" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Share diagnostics" + } + } + } + }, + "error_camera" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Camera access is required to scan a QR code." + } + } + } + }, + "error_device_info" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Could not load device information." + } + } + } + }, + "error_filesystem" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "VniDrop could not access the selected files or folder. Check permissions and try again." + } + } + } + }, + "error_generic" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Something went wrong. Try again." + } + } + } + }, + "error_initialization" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "VniDrop could not finish starting up. Close the app and try again." + } + } + } + }, + "error_invalid_ticket" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "This invitation could not be read. Ask the sender for a new one." + } + } + } + }, + "error_invitation_empty" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "That invitation is empty. Try opening it again." + } + } + } + }, + "error_missing_native_library" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The native VniDrop library is missing from this build." + } + } + } + }, + "error_nfc" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "This NFC tag could not be used. Try another tag." + } + } + } + }, + "error_permission" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The sender has not approved this transfer, or it was refused." + } + } + } + }, + "error_repository" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "VniDrop could not save transfer data on this device." + } + } + } + }, + "error_selection_failed" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Could not open the selected item. Try choosing it again." + } + } + } + }, + "error_share_empty" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Select at least one item to share." + } + } + } + }, + "error_socket_bind" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "VniDrop could not open its network sockets on this device." + } + } + } + }, + "error_starting_up" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "VniDrop is still starting. Open the invitation again in a moment." + } + } + } + }, + "error_transfer" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The transfer could not be completed. Check your connection and try again." + } + } + } + }, + "field_receiver_name" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Receiver name" + } + } + } + }, + "field_sender_name" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sender name" + } + } + } + }, + "field_transfer_name" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Transfer name" + } + } + } + }, + "field_username" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Display name" + } + } + } + }, + "folder_status_permission_required" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Permission needed" + } + } + } + }, + "folder_status_unavailable" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Unavailable" + } + } + } + }, + "folder_status_validating" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Checking folder…" + } + } + } + }, + "folder_status_writable" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ready" + } + } + } + }, + "metadata_files" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Files" + } + } + } + }, + "metadata_size" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Size" + } + } + } + }, + "metadata_status" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Status" + } + } + } + }, + "nav_receive" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Receive" + } + } + } + }, + "nav_send" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Send" + } + } + } + }, + "nav_settings" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Settings" + } + } + } + }, + "network_title" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Network" + } + } + } + }, + "notifications_description" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Get notified about new receive requests while VniDrop is in the background." + } + } + } + }, + "notifications_enabled_message" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Notifications enabled." + } + } + } + }, + "notifications_local_title" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Allow notifications" + } + } + } + }, + "notifications_permission_denied" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Notifications are turned off for VniDrop. You can enable them in Settings." + } + } + } + }, + "notifications_settings_open_failed" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Could not open notification settings." + } + } + } + }, + "notifications_title" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Notifications" + } + } + } + }, + "notifications_unsupported" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Notifications are not available on this device." + } + } + } + }, + "os_version_title" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Operating system" + } + } + } + }, + "preferences_receive_folder_title" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Save received transfers to" + } + } + } + }, + "preferences_title" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Preferences" + } + } + } + }, + "progress_cancelled" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cancelled" + } + } + } + }, + "progress_completed" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Completed" + } + } + } + }, + "progress_connected" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Connected" + } + } + } + }, + "progress_connecting" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Connecting" + } + } + } + }, + "progress_downloading" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Downloading" + } + } + } + }, + "progress_failed" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Failed" + } + } + } + }, + "progress_getting_ready" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Getting ready" + } + } + } + }, + "progress_interrupted" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Transfer interrupted" + } + } + } + }, + "progress_preparing" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Preparing" + } + } + } + }, + "progress_ready" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ready" + } + } + } + }, + "progress_receiving" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Receiving" + } + } + } + }, + "progress_requesting_access" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Requesting access" + } + } + } + }, + "progress_saving" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Saving" + } + } + } + }, + "progress_sending" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sending" + } + } + } + }, + "progress_sending_to_count" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sending to %lld" + } + } + } + }, + "progress_share_ready" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ready to share" + } + } + } + }, + "progress_working" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Working…" + } + } + } + }, + "receive_choose_method_body" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Choose the invitation method available to you." + } + } + } + }, + "receive_choose_method_title" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "How would you like to connect?" + } + } + } + }, + "receive_clear_history" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Clear history" + } + } + } + }, + "receive_clear_history_description" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "All completed, failed, and cancelled receives will be removed from VniDrop’s history. Downloaded files will remain on this device." + } + } + } + }, + "receive_clear_history_title" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Clear receive history?" + } + } + } + }, + "receive_completed" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Transfer received." + } + } + } + }, + "receive_delete_history_description" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "“%1$@” will be removed from VniDrop’s history. The downloaded file will remain on this device." + } + } + } + }, + "receive_delete_history_item" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Delete from receive history" + } + } + } + }, + "receive_delete_history_title" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Remove from history?" + } + } + } + }, + "receive_empty_body" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Open a VniDrop invitation, scan a QR code, or hold near an NFC tag." + } + } + } + }, + "receive_empty_title" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nothing received yet" + } + } + } + }, + "receive_history_cleared" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Receive history cleared." + } + } + } + }, + "receive_history_title" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "History" + } + } + } + }, + "receive_method_file" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Open a .vnd invitation" + } + } + } + }, + "receive_method_file_description" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Choose an invitation saved or shared to this device." + } + } + } + }, + "receive_method_nfc" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Read NFC tag" + } + } + } + }, + "receive_method_nfc_description" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hold this device near the sender’s invitation tag." + } + } + } + }, + "receive_method_scan" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Scan QR code" + } + } + } + }, + "receive_method_scan_description" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Use the camera to scan the sender’s VniDrop code." + } + } + } + }, + "receive_new_subtitle" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Transfers you’ve received on this device." + } + } + } + }, + "receive_nfc_waiting" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hold near the NFC tag…" + } + } + } + }, + "receive_open_files_failed" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Couldn’t open VniDrop in Files." + } + } + } + }, + "receive_review_title" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Review transfer" + } + } + } + }, + "receive_title" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Receive" + } + } + } + }, + "receive_unknown_transfer" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "VniDrop transfer" + } + } + } + }, + "send_access_anyone" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Anyone with this transfer" + } + } + } + }, + "send_access_anyone_description" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No approval is required. Only use this for items you are comfortable sharing." + } + } + } + }, + "send_access_anyone_warning" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Anyone with the invitation can download until you stop sharing. Do not use this for private or sensitive items." + } + } + } + }, + "send_access_approval" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ask before each download" + } + } + } + }, + "send_access_approval_description" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "You approve or refuse every new receiver." + } + } + } + }, + "send_access_title" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Who can receive it?" + } + } + } + }, + "send_choose_file_body" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Select files or a folder from this device. You can review the selection before creating the transfer." + } + } + } + }, + "send_choose_file_title" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Choose what to share" + } + } + } + }, + "send_empty_body" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Create a transfer, decide who can receive it, then invite them with a QR code, NFC tag, or invitation file." + } + } + } + }, + "send_empty_title" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nothing shared yet" + } + } + } + }, + "send_file_size_unknown" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Size unavailable" + } + } + } + }, + "send_folder_label" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Folder" + } + } + } + }, + "send_new_transfer_description" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Create a new transfer" + } + } + } + }, + "send_new_transfer_title" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "New transfer" + } + } + } + }, + "send_review_title" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Review transfer" + } + } + } + }, + "send_selected_files_count" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$d files selected" + } + } + } + }, + "send_subtitle" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Transfers you’re sharing from this device." + } + } + } + }, + "send_stop_sharing" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Stop sharing" + } + } + } + }, + "send_stop_sharing_description" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "This stops the transfer and interrupts anyone currently downloading it. It stays in your history as Stopped." + } + } + } + }, + "send_title" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Send" + } + } + } + }, + "send_transfer_created" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Transfer created." + } + } + } + }, + "send_transfer_details_title" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Transfer details" + } + } + } + }, + "send_transfers_title" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Your transfers" + } + } + } + }, + "settings_subtitle" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Your name, where transfers are saved, appearance, and notifications." + } + } + } + }, + "settings_title" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Settings" + } + } + } + }, + "snackbar_dismiss" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dismiss" + } + } + } + }, + "storage_calculating" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Calculating…" + } + } + } + }, + "storage_delete_transfers" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Delete all transfers" + } + } + } + }, + "storage_delete_transfers_description" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "This clears all sent and received transfer records from your history. Your received files are not deleted. Note: the transfer engine keeps its stored content, so Transfer data may not decrease. This can’t be undone." + } + } + } + }, + "storage_deleting" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Deleting…" + } + } + } + }, + "storage_footer" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Transfer data is managed by the transfer engine — your history plus the content of files you’ve shared. The button below clears your transfer records; the engine keeps its stored content, so this value may not drop. Received files are your downloaded files and are never deleted here." + } + } + } + }, + "storage_received_files" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Received files" + } + } + } + }, + "storage_temporary" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Temporary files" + } + } + } + }, + "storage_title" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Storage" + } + } + } + }, + "storage_total" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Total" + } + } + } + }, + "storage_transfer_data" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Transfer data" + } + } + } + }, + "storage_transfers_deleted" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "All transfers deleted" + } + } + } + }, + "status_available" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Available" + } + } + } + }, + "status_cancelled" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cancelled" + } + } + } + }, + "status_completed" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Completed" + } + } + } + }, + "status_failed" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Failed" + } + } + } + }, + "status_preparing" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Preparing" + } + } + } + }, + "status_receiving" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Receiving" + } + } + } + }, + "status_stopped" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Stopped" + } + } + } + }, + "transfer_activity_description" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "See important updates for this transfer" + } + } + } + }, + "transfer_activity_title" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Activity" + } + } + } + }, + "transfer_delete_description" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "“%1$@” will stop being shared and its transfer history will be removed from this device." + } + } + } + }, + "transfer_delete_title" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Delete transfer?" + } + } + } + }, + "transfer_deleted" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Transfer deleted." + } + } + } + }, + "transfer_deleting" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Deleting…" + } + } + } + }, + "transfer_details_title" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Transfer details" + } + } + } + }, + "transfer_event_approved" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Receiver access approved" + } + } + } + }, + "transfer_event_completed" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "A receiver completed the transfer" + } + } + } + }, + "transfer_event_connecting" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Connecting to sender" + } + } + } + }, + "transfer_event_downloading" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Downloading" + } + } + } + }, + "transfer_event_failed" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The transfer encountered a problem" + } + } + } + }, + "transfer_event_preparing" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Preparing your transfer" + } + } + } + }, + "transfer_event_ready" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ready to share" + } + } + } + }, + "transfer_event_refused" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Receiver access refused" + } + } + } + }, + "transfer_event_requested" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "A receiver requested access" + } + } + } + }, + "transfer_event_saving" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Saving" + } + } + } + }, + "transfer_event_stopped" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sharing stopped" + } + } + } + }, + "transfer_event_updated" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Transfer updated" + } + } + } + }, + "transfer_file_count_one" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$d file" + } + } + } + }, + "transfer_file_count_other" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$d files" + } + } + } + }, + "transfer_invitation_saved" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Invitation saved." + } + } + } + }, + "transfer_nearby_device" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nearby device" + } + } + } + }, + "transfer_nfc_unavailable" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "NFC tag writing is not available on this device." + } + } + } + }, + "transfer_nfc_waiting" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hold your device near a writable NFC tag." + } + } + } + }, + "transfer_nfc_written" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Invitation written to the NFC tag." + } + } + } + }, + "transfer_no_activity" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "There is no activity to show yet." + } + } + } + }, + "transfer_no_receivers" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nobody has requested this transfer yet." + } + } + } + }, + "transfer_receiver_accepted" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Approved — waiting for completion" + } + } + } + }, + "transfer_receiver_completed" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Received successfully" + } + } + } + }, + "transfer_receiver_expired" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Request expired" + } + } + } + }, + "transfer_receiver_refused" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Request refused" + } + } + } + }, + "transfer_receiver_requested" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Waiting for your approval" + } + } + } + }, + "transfer_receiver_unknown" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Status unavailable" + } + } + } + }, + "transfer_receivers_completed_count" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$d completed" + } + } + } + }, + "transfer_receivers_description" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Requests, approvals, and completed deliveries" + } + } + } + }, + "transfer_receivers_pending" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$d waiting" + } + } + } + }, + "transfer_receivers_title" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Receivers" + } + } + } + }, + "transfer_scan_qr" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Scan with VniDrop to receive this transfer" + } + } + } + }, + "transfer_share_description" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "QR code, invitation file, and nearby options" + } + } + } + }, + "transfer_share_title" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Share" + } + } + } + }, + "value_unavailable" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Not available" + } + } + } + }, + "version_title" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "App version" + } + } + } + } + }, + "version" : "1.0" +} \ No newline at end of file diff --git a/apple/VniDrop/Resources/VniDrop.entitlements b/apple/VniDrop/Resources/VniDrop.entitlements new file mode 100644 index 0000000..f6d6b13 --- /dev/null +++ b/apple/VniDrop/Resources/VniDrop.entitlements @@ -0,0 +1,24 @@ + + + + + + com.apple.developer.nfc.readersession.formats + + NDEF + + + + com.apple.security.app-sandbox + + com.apple.security.files.user-selected.read-write + + com.apple.security.files.downloads.read-write + + com.apple.security.network.client + + com.apple.security.network.server + + + diff --git a/apple/VniDrop/UI/Components/AdaptiveDrawer.swift b/apple/VniDrop/UI/Components/AdaptiveDrawer.swift new file mode 100644 index 0000000..2d19e20 --- /dev/null +++ b/apple/VniDrop/UI/Components/AdaptiveDrawer.swift @@ -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: 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: 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( + isPresented: Binding, + windowClass: WindowClass, + onDismiss: @escaping () -> Void, + @ViewBuilder content: @escaping () -> DrawerContent + ) -> some View { + modifier(AdaptiveDrawer( + isPresented: isPresented, windowClass: windowClass, + onDismiss: onDismiss, drawerContent: content + )) + } +} diff --git a/apple/VniDrop/UI/Components/Buttons.swift b/apple/VniDrop/UI/Components/Buttons.swift new file mode 100644 index 0000000..700dc22 --- /dev/null +++ b/apple/VniDrop/UI/Components/Buttons.swift @@ -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) + } +} + diff --git a/apple/VniDrop/UI/Components/Components.swift b/apple/VniDrop/UI/Components/Components.swift new file mode 100644 index 0000000..7d2b504 --- /dev/null +++ b/apple/VniDrop/UI/Components/Components.swift @@ -0,0 +1,96 @@ +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 + /// Pre-resolved label; when set it overrides `labelKey`. + var labelText: String? = nil + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + HStack { + label.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) + } + + @ViewBuilder + private var label: some View { + if let labelText { + Text(labelText) + } else { + Text(LocalizedStringKey(labelKey)) + } + } +} + +// 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) + } + } +} + diff --git a/apple/VniDrop/UI/Components/PlatformImage.swift b/apple/VniDrop/UI/Components/PlatformImage.swift new file mode 100644 index 0000000..14c02a2 --- /dev/null +++ b/apple/VniDrop/UI/Components/PlatformImage.swift @@ -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 diff --git a/apple/VniDrop/UI/Feedback/SnackbarHost.swift b/apple/VniDrop/UI/Feedback/SnackbarHost.swift new file mode 100644 index 0000000..c49ec51 --- /dev/null +++ b/apple/VniDrop/UI/Feedback/SnackbarHost.swift @@ -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? + + 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() + } +} diff --git a/apple/VniDrop/UI/Feedback/UiMessage.swift b/apple/VniDrop/UI/Feedback/UiMessage.swift new file mode 100644 index 0000000..2a71c8c --- /dev/null +++ b/apple/VniDrop/UI/Feedback/UiMessage.swift @@ -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)) + } +} diff --git a/apple/VniDrop/UI/Feedback/UserFacingError.swift b/apple/VniDrop/UI/Feedback/UserFacingError.swift new file mode 100644 index 0000000..2859884 --- /dev/null +++ b/apple/VniDrop/UI/Feedback/UserFacingError.swift @@ -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 +} diff --git a/apple/VniDrop/UI/Navigation/AppDestination.swift b/apple/VniDrop/UI/Navigation/AppDestination.swift new file mode 100644 index 0000000..1a755bd --- /dev/null +++ b/apple/VniDrop/UI/Navigation/AppDestination.swift @@ -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" + } + } +} diff --git a/apple/VniDrop/UI/Theme/Typography.swift b/apple/VniDrop/UI/Theme/Typography.swift new file mode 100644 index 0000000..6b7812d --- /dev/null +++ b/apple/VniDrop/UI/Theme/Typography.swift @@ -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) +} diff --git a/apple/VniDrop/UI/Theme/VniDropColors.swift b/apple/VniDrop/UI/Theme/VniDropColors.swift new file mode 100644 index 0000000..3d7c8dd --- /dev/null +++ b/apple/VniDrop/UI/Theme/VniDropColors.swift @@ -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)) +} diff --git a/apple/VniDrop/UI/Theme/VniDropTheme.swift b/apple/VniDrop/UI/Theme/VniDropTheme.swift new file mode 100644 index 0000000..4e0153a --- /dev/null +++ b/apple/VniDrop/UI/Theme/VniDropTheme.swift @@ -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)) + } +} diff --git a/apple/VnidropCore/Package.swift b/apple/VnidropCore/Package.swift new file mode 100644 index 0000000..01a7906 --- /dev/null +++ b/apple/VnidropCore/Package.swift @@ -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"] + ), + ] +) diff --git a/apple/VnidropCore/README.md b/apple/VnidropCore/README.md new file mode 100644 index 0000000..2c71fa6 --- /dev/null +++ b/apple/VnidropCore/README.md @@ -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. diff --git a/apple/project.yml b/apple/project.yml new file mode 100644 index 0000000..3f643f6 --- /dev/null +++ b/apple/project.yml @@ -0,0 +1,80 @@ +# 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] + configFiles: + Debug: Signing.xcconfig + Release: Signing.xcconfig + 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 + # Mirror the Info.plist identity so Xcode's Identity editor shows it too + # (the editor reads these build settings, not the manual plist). + INFOPLIST_KEY_CFBundleDisplayName: VniDrop + INFOPLIST_KEY_LSApplicationCategoryType: public.app-category.utilities + SWIFT_VERSION: "6.0" + SWIFT_STRICT_CONCURRENCY: complete + 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 + + VniDropTests: + type: bundle.unit-test + supportedDestinations: [iOS, macOS] + sources: + - path: Tests + settings: + base: + GENERATE_INFOPLIST_FILE: YES + SWIFT_VERSION: "6.0" + SWIFT_STRICT_CONCURRENCY: complete + CODE_SIGN_STYLE: Automatic + dependencies: + - target: VniDrop + +schemes: + VniDrop: + build: + targets: + VniDrop: all + run: + config: Debug + test: + config: Debug + targets: + - VniDropTests diff --git a/apple/scripts/build-core.sh b/apple/scripts/build-core.sh new file mode 100755 index 0000000..77f89c6 --- /dev/null +++ b/apple/scripts/build-core.sh @@ -0,0 +1,105 @@ +#!/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 SwiftUI app's deployment targets (apple/project.yml) so the static +# libs are never built for a newer OS than the app links against. +export IPHONEOS_DEPLOYMENT_TARGET="${IPHONEOS_DEPLOYMENT_TARGET:-18.2}" +export MACOSX_DEPLOYMENT_TARGET="${MACOSX_DEPLOYMENT_TARGET:-15.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" diff --git a/crates/uniffi-bindgen/Cargo.toml b/crates/uniffi-bindgen/Cargo.toml new file mode 100644 index 0000000..31800d7 --- /dev/null +++ b/crates/uniffi-bindgen/Cargo.toml @@ -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"] } diff --git a/crates/uniffi-bindgen/src/main.rs b/crates/uniffi-bindgen/src/main.rs new file mode 100644 index 0000000..f6cff6c --- /dev/null +++ b/crates/uniffi-bindgen/src/main.rs @@ -0,0 +1,3 @@ +fn main() { + uniffi::uniffi_bindgen_main() +} diff --git a/iosApp/iosApp.xcodeproj/project.pbxproj b/iosApp/iosApp.xcodeproj/project.pbxproj index f4eaddb..b50b454 100644 --- a/iosApp/iosApp.xcodeproj/project.pbxproj +++ b/iosApp/iosApp.xcodeproj/project.pbxproj @@ -152,7 +152,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "if [ \"YES\" = \"$OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED\" ]; then\n echo \"Skipping Gradle build task invocation due to OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED environment variable set to \\\"YES\\\"\"\n exit 0\nfi\ncd \"$SRCROOT/..\"\n./gradlew :shared:embedAndSignAppleFrameworkForXcode\n"; + shellScript = "export PATH=\"$HOME/.cargo/bin:/opt/homebrew/bin:/usr/local/bin:$PATH\"\nexport CARGO_PROFILE_DEV_STRIP=none\nexport JAVA_HOME=$(/usr/libexec/java_home)\n\nif [ \"YES\" = \"$OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED\" ]; then\n echo \"Skipping Gradle build task invocation due to OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED environment variable set to \\\"YES\\\"\"\n exit 0\nfi\ncd \"$SRCROOT/..\"\n./gradlew :shared:embedAndSignAppleFrameworkForXcode\n"; }; /* End PBXShellScriptBuildPhase section */ @@ -167,6 +167,64 @@ /* End PBXSourcesBuildPhase section */ /* Begin XCBuildConfiguration section */ + 5EDFED06EDA142275594F3F7 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReferenceAnchor = B93EE283488EDD1107331E67 /* Configuration */; + baseConfigurationReferenceRelativePath = Config.xcconfig; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 18.2; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; 6A37768B9DA3138100D19D47 /* Debug */ = { isa = XCBuildConfiguration; baseConfigurationReferenceAnchor = B93EE283488EDD1107331E67 /* Configuration */; @@ -232,64 +290,6 @@ }; name = Debug; }; - 5EDFED06EDA142275594F3F7 /* Release */ = { - isa = XCBuildConfiguration; - baseConfigurationReferenceAnchor = B93EE283488EDD1107331E67 /* Configuration */; - baseConfigurationReferenceRelativePath = Config.xcconfig; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; - CLANG_ANALYZER_NONNULL = YES; - CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_ENABLE_OBJC_WEAK = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_COMMA = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_STRICT_PROTOTYPES = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; - CLANG_WARN_UNREACHABLE_CODE = YES; - CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - ENABLE_NS_ASSERTIONS = NO; - ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_USER_SCRIPT_SANDBOXING = NO; - GCC_C_LANGUAGE_STANDARD = gnu17; - GCC_NO_COMMON_BLOCKS = YES; - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNDECLARED_SELECTOR = YES; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 18.2; - LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MTL_ENABLE_DEBUG_INFO = NO; - MTL_FAST_MATH = YES; - SDKROOT = iphoneos; - SWIFT_COMPILATION_MODE = wholemodule; - VALIDATE_PRODUCT = YES; - }; - name = Release; - }; 87E567D3634C99D02D035476 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -300,10 +300,11 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\""; - DEVELOPMENT_TEAM = "${TEAM_ID}"; + DEVELOPMENT_TEAM = A8A4JSMV5D; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = iosApp/Info.plist; + INFOPLIST_KEY_LSSupportsOpeningDocumentsInPlace = YES; INFOPLIST_KEY_NFCReaderUsageDescription = "VniDrop uses NFC to read transfer invitation tags."; INFOPLIST_KEY_NSCameraUsageDescription = "VniDrop uses the camera to scan transfer QR codes."; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; @@ -342,10 +343,11 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\""; - DEVELOPMENT_TEAM = "${TEAM_ID}"; + DEVELOPMENT_TEAM = A8A4JSMV5D; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = iosApp/Info.plist; + INFOPLIST_KEY_LSSupportsOpeningDocumentsInPlace = YES; INFOPLIST_KEY_NFCReaderUsageDescription = "VniDrop uses NFC to read transfer invitation tags."; INFOPLIST_KEY_NSCameraUsageDescription = "VniDrop uses the camera to scan transfer QR codes."; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; @@ -377,15 +379,6 @@ /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ - FE990A962A8E8AC95D51FA0E /* Build configuration list for PBXProject "iosApp" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 6A37768B9DA3138100D19D47 /* Debug */, - 5EDFED06EDA142275594F3F7 /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; 94A2E337CA60E8CC4A507E7E /* Build configuration list for PBXNativeTarget "iosApp" */ = { isa = XCConfigurationList; buildConfigurations = ( @@ -395,6 +388,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + FE990A962A8E8AC95D51FA0E /* Build configuration list for PBXProject "iosApp" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 6A37768B9DA3138100D19D47 /* Debug */, + 5EDFED06EDA142275594F3F7 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ }; rootObject = F60687F0AEE04DF31E2599D0 /* Project object */; diff --git a/iosApp/iosApp/Info.plist b/iosApp/iosApp/Info.plist index 70668c1..f74686a 100644 --- a/iosApp/iosApp/Info.plist +++ b/iosApp/iosApp/Info.plist @@ -19,6 +19,14 @@ + UIBackgroundModes + + fetch + processing + remote-notification + + UIViewControllerBasedStatusBarAppearance + UTExportedTypeDeclarations @@ -51,5 +59,11 @@ VniDrop uses NFC to read transfer invitation tags. UIViewControllerBasedStatusBarAppearance + NSLocalNetworkUsageDescription + VniDrop needs local network access to send to other local devices if needed. + NSBonjourServices + + +