diff --git a/.github/workflows/shared-kmp.yml b/.github/workflows/shared-kmp.yml index de5d103..6c2fa73 100644 --- a/.github/workflows/shared-kmp.yml +++ b/.github/workflows/shared-kmp.yml @@ -44,8 +44,7 @@ concurrency: jobs: jvm-test: # Host Rust embedding is enabled only for the current Gobley host target. - # This job stays on macOS to cover the Apple targets as well as JVM tests. - runs-on: macos-latest + runs-on: ubuntu-latest timeout-minutes: 75 steps: - name: Checkout @@ -63,7 +62,7 @@ jobs: - name: Install Rust toolchain uses: dtolnay/rust-toolchain@stable with: - targets: aarch64-apple-darwin,aarch64-linux-android,x86_64-linux-android + targets: aarch64-linux-android,x86_64-linux-android - name: Cache Cargo uses: actions/cache@v4 diff --git a/AGENTS.md b/AGENTS.md index ddf1ccc..17912ac 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,13 +13,14 @@ Nested guides take precedence when editing under those trees: ## Project overview -VniDrop is a cross-platform **local P2P file transfer** app (Android, iOS, Desktop). +VniDrop is a cross-platform **local P2P file transfer** app. | Layer | Path | Responsibility | |-------|------|----------------| | Rust core | `crates/vnidrop/` | Iroh endpoint, blobs, SQLite, tickets, approval, streaming | -| Shared KMP | `shared/` | Compose UI, ViewModels, expect/actual platform bridges | -| Hosts | `androidApp/`, `iosApp/`, `desktopApp/` | Thin app shells | +| Shared KMP | `shared/` | Compose UI and platform bridges for Android, Windows, and Linux | +| Compose hosts | `androidApp/`, `desktopApp/` | Thin Android and Windows/Linux app shells | +| Apple app | `apple/` | Native SwiftUI UI using generated Rust/UniFFI Swift bindings | **Invariant:** UI/platform opens files and handles pickers; **Rust streams bytes**. Do not design features that move transfer payloads through Kotlin heap by default. @@ -53,7 +54,7 @@ Domain docs (reference, do not paste into PRs): ## Build and test Install prerequisites when missing: Rust stable + rustfmt + clippy, JDK 17, -Android NDK/SDK only if building Android, Xcode only for iOS. +Android NDK/SDK only if building Android, Xcode only for the native Apple app. ### Rust core (`crates/vnidrop` or workspace root) @@ -95,15 +96,13 @@ Other targets (slower / machine-dependent): ```bash ./gradlew :shared:testAndroidHostTest -./gradlew :shared:iosSimulatorArm64Test # macOS + Xcode ./gradlew :androidApp:assembleDebug ./gradlew :desktopApp:run ``` -**Note:** `jvmTest` CI currently runs on **macOS**. Gobley host cargo is enabled -for the current host and architecture, so local Linux and Windows builds embed -their matching desktop Rust library. Prefer macOS only when exact CI parity is -required. +**Note:** `jvmTest` CI runs on **Linux**. Gobley host cargo is enabled for the +current host and architecture, so local desktop builds embed their matching +Rust library. ### What to run before finishing @@ -144,12 +143,12 @@ shared/src/commonMain/kotlin/com/vnidrop/app/ core/ # CoreGateway, models, pickers interfaces feature/send|receive|approvals|settings|app/ ui/theme|components|navigation|feedback|state/ -androidMain|iosMain|jvmMain/ # expect/actual implementations +androidMain|jvmMain/ # expect/actual implementations ``` ### Platform file rules (do not violate) -- Desktop / path-based iOS: paths; directory walk in Rust when `is_directory`. +- Windows/Linux desktop: paths; directory walk in Rust when `is_directory`. - Android **share**: ParcelFileDescriptor **file** FDs only — never a directory FD. Folder share expands SAF trees in Kotlin to per-file FDs + relative names. - Android **receive** default: MediaStore Downloads sink; custom trees via SAF write. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 58aaf30..b7606a6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -38,7 +38,7 @@ Install the tools needed for the area you plan to change: - JDK 17 or newer for Gradle and application builds - Rust stable with `rustfmt` and Clippy for the transfer core - Android SDK and NDK for Android builds -- Xcode on macOS for iOS builds and simulator tests +- Xcode and XcodeGen on macOS for native Apple builds and simulator tests - Node.js 22.12 or newer for the optional diagnostics service The first Rust and Gradle builds may take several minutes while dependencies are @@ -49,10 +49,10 @@ downloaded and native components are compiled. | Path | Purpose | |------|---------| | `crates/vnidrop/` | Rust transfer core, persistence, approval, and streaming | -| `shared/` | Shared Kotlin Multiplatform UI and platform bridges | +| `shared/` | Compose Multiplatform UI and bridges for Android, Windows, and Linux | | `androidApp/` | Android application shell | -| `iosApp/` | iOS application shell | -| `desktopApp/` | Desktop JVM application shell | +| `desktopApp/` | Windows/Linux JVM application shell | +| `apple/` | Native SwiftUI application and Rust/UniFFI integration for Apple platforms | | `services/diagnostics-api/` | Optional Cloudflare diagnostics service | Read the nearest contributor guidance before editing: @@ -114,10 +114,22 @@ Platform-specific checks may also be appropriate: ```bash ./gradlew :shared:testAndroidHostTest -./gradlew :shared:iosSimulatorArm64Test ./gradlew :androidApp:assembleDebug ``` +### Native Apple App + +```bash +cd apple +./scripts/build-core.sh debug +xcodegen generate +xcodebuild test \ + -project VniDrop.xcodeproj \ + -scheme VniDrop \ + -destination 'platform=iOS Simulator,name=iPhone 16' \ + CODE_SIGNING_ALLOWED=NO +``` + ### Diagnostics Service ```bash diff --git a/README.md b/README.md index 454ee63..e376c17 100644 --- a/README.md +++ b/README.md @@ -88,7 +88,8 @@ people, especially when using **Anyone with this transfer**. - Per-receiver requests, approvals, progress, and delivery status - Cancel, stop sharing, and local transfer history - Safe receive destinations that do not silently overwrite existing files -- Android, iOS, and desktop apps built from a shared Compose Multiplatform UI +- Native SwiftUI apps on iOS, iPadOS, and macOS; Compose apps on Android, + Windows, and Linux - Opt-in diagnostics with transfer contents, invitations, and file paths excluded @@ -117,14 +118,17 @@ if you want to try the current version. git clone https://github.com/vnidrop/vnidrop.git cd vnidrop -# Desktop +# Windows/Linux desktop ./gradlew :desktopApp:run # Android debug build ./gradlew :androidApp:assembleDebug -# iOS -open iosApp/iosApp.xcodeproj +# iOS, iPadOS, and macOS +cd apple +./scripts/build-core.sh debug +xcodegen generate +open VniDrop.xcodeproj ``` See [`CONTRIBUTING.md`](CONTRIBUTING.md) for prerequisites, development setup, diff --git a/apple/README.md b/apple/README.md index 205e549..69f3a2f 100644 --- a/apple/README.md +++ b/apple/README.md @@ -3,7 +3,7 @@ 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. +UI screen-for-screen. Android, Windows, and Linux continue to use `shared/` + Compose. ## Layout diff --git a/apple/VniDrop/App/VniDropApp.swift b/apple/VniDrop/App/VniDropApp.swift index a684007..0119980 100644 --- a/apple/VniDrop/App/VniDropApp.swift +++ b/apple/VniDrop/App/VniDropApp.swift @@ -1,6 +1,6 @@ import SwiftUI -/// App entry point for iOS/iPadOS/macOS, ported from `iOSApp.swift` + `App.kt`. +/// Native app entry point for iOS, iPadOS, and macOS. /// Opens `.vnd` invitations via `onOpenURL` and routes them to the receive flow. @main struct VniDropApp: App { diff --git a/apple/VniDrop/Core/LocalNotificationService.swift b/apple/VniDrop/Core/LocalNotificationService.swift index 5ab903c..4f04f68 100644 --- a/apple/VniDrop/Core/LocalNotificationService.swift +++ b/apple/VniDrop/Core/LocalNotificationService.swift @@ -16,8 +16,7 @@ struct LocalNotification { let body: String } -/// Local notification service, ported from `LocalNotificationService.kt` / -/// `.ios.kt`, backed by `UNUserNotificationCenter`. +/// Local notification service backed by `UNUserNotificationCenter`. @MainActor final class LocalNotificationService: ObservableObject { @Published private(set) var permission: NotificationPermission = .notDetermined diff --git a/apple/VniDrop/Features/Receive/ReceiveInvitationActions.swift b/apple/VniDrop/Features/Receive/ReceiveInvitationActions.swift index 06e4db8..9fb38b7 100644 --- a/apple/VniDrop/Features/Receive/ReceiveInvitationActions.swift +++ b/apple/VniDrop/Features/Receive/ReceiveInvitationActions.swift @@ -2,7 +2,7 @@ import SwiftUI enum ReceiveMethodAvailability { case available, unavailable, hidden } -/// Invitation acquisition actions, ported from `ReceiveInvitationActions` (iosMain). +/// Invitation acquisition actions shared by the native Apple feature models. @MainActor protocol ReceiveInvitationActions: AnyObject { var fileAvailability: ReceiveMethodAvailability { get } diff --git a/apple/VniDrop/Features/Send/FilePreviewRepository.swift b/apple/VniDrop/Features/Send/FilePreviewRepository.swift index 8c6333b..f1bc969 100644 --- a/apple/VniDrop/Features/Send/FilePreviewRepository.swift +++ b/apple/VniDrop/Features/Send/FilePreviewRepository.swift @@ -1,8 +1,7 @@ import Foundation import Combine -/// Persisted per-transfer thumbnail store, ported from -/// `feature/send/FilePreviewRepository.kt` + `PlatformPreviewStore.ios.kt`. +/// Native Apple preview cache and thumbnail loader. /// Only small PNG/JPEG/WEBP previews are retained, under a total quota. struct PreviewStoragePolicy { var maxEntryBytes: Int = 512 * 1024 diff --git a/apple/VniDrop/Features/Send/TransferShareActions.swift b/apple/VniDrop/Features/Send/TransferShareActions.swift index 02b93f5..73ca4df 100644 --- a/apple/VniDrop/Features/Send/TransferShareActions.swift +++ b/apple/VniDrop/Features/Send/TransferShareActions.swift @@ -2,7 +2,7 @@ import SwiftUI enum NfcShareAvailability { case available, unavailable, hidden } -/// Invitation delivery actions, ported from `TransferShareActions` (iosMain). +/// Invitation delivery actions shared by the native Apple feature models. /// Platform implementations perform export, native share, and NFC write. @MainActor protocol TransferShareActions: AnyObject { diff --git a/apple/VniDrop/Platform/FileSystemService+iOS.swift b/apple/VniDrop/Platform/FileSystemService+iOS.swift index e9f4a37..b0fcc51 100644 --- a/apple/VniDrop/Platform/FileSystemService+iOS.swift +++ b/apple/VniDrop/Platform/FileSystemService+iOS.swift @@ -3,7 +3,7 @@ import Foundation import UIKit import VnidropCore -/// iOS file system service, ported from `FileSystemService.ios.kt`. +/// Native iOS file system service. /// 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 { diff --git a/apple/VniDrop/Platform/InvitationFile.swift b/apple/VniDrop/Platform/InvitationFile.swift index 25a77cb..743f606 100644 --- a/apple/VniDrop/Platform/InvitationFile.swift +++ b/apple/VniDrop/Platform/InvitationFile.swift @@ -1,7 +1,6 @@ import Foundation -/// Builds a `.vnd` invitation filename from a transfer name, mirroring the iOS -/// helper in `TransferShareActions.ios.kt`. +/// Builds a `.vnd` invitation filename from a transfer name. func invitationFileName(_ transferName: String) -> String { let trimmed = transferName.trimmingCharacters(in: .whitespacesAndNewlines) let base = trimmed.isEmpty ? "invitation" : trimmed diff --git a/apple/VniDrop/Platform/ReceiveInvitationActions+iOS.swift b/apple/VniDrop/Platform/ReceiveInvitationActions+iOS.swift index ba0ba3f..e0610d0 100644 --- a/apple/VniDrop/Platform/ReceiveInvitationActions+iOS.swift +++ b/apple/VniDrop/Platform/ReceiveInvitationActions+iOS.swift @@ -7,7 +7,7 @@ import UniformTypeIdentifiers @MainActor func makeReceiveInvitationActions() -> ReceiveInvitationActions { IosReceiveInvitationActions() } -/// iOS invitation acquisition, ported from `ReceiveInvitationActions.ios.kt`: +/// iOS invitation acquisition: /// document picker, camera QR scanner, and NFC read. final class IosReceiveInvitationActions: NSObject, ReceiveInvitationActions, UIDocumentPickerDelegate { private var documentResult: ((Result) -> Void)? diff --git a/apple/VniDrop/Platform/TransferShareActions+iOS.swift b/apple/VniDrop/Platform/TransferShareActions+iOS.swift index 2f4229c..c9fe5fd 100644 --- a/apple/VniDrop/Platform/TransferShareActions+iOS.swift +++ b/apple/VniDrop/Platform/TransferShareActions+iOS.swift @@ -5,7 +5,7 @@ import UIKit @MainActor func makePlatformShareActions() -> TransferShareActions { IosTransferShareActions() } -/// iOS invitation delivery, ported from `TransferShareActions.ios.kt`: export via +/// iOS invitation delivery: export via /// document picker, native share via `UIActivityViewController`, and NFC write. final class IosTransferShareActions: NSObject, TransferShareActions { private var nfcWriter: InvitationNfcWriter? @@ -61,8 +61,7 @@ final class IosTransferShareActions: NSObject, TransferShareActions { } } -/// Writes a VniDrop invitation to a writable NDEF tag, ported from -/// `InvitationNfcWriter` in `TransferShareActions.ios.kt`. +/// Writes a VniDrop invitation to a writable NDEF tag. // Runs entirely on the NFC session's `.main` delegate queue. final class InvitationNfcWriter: NSObject, NFCNDEFReaderSessionDelegate, @unchecked Sendable { private let ticket: String diff --git a/apple/VniDrop/Resources/VniDrop.entitlements b/apple/VniDrop/Resources/VniDrop.entitlements index f6d6b13..09743a1 100644 --- a/apple/VniDrop/Resources/VniDrop.entitlements +++ b/apple/VniDrop/Resources/VniDrop.entitlements @@ -2,7 +2,7 @@ - + com.apple.developer.nfc.readersession.formats NDEF diff --git a/assets/ios/app-icon.png b/assets/ios/app-icon.png deleted file mode 100644 index 91e521f..0000000 Binary files a/assets/ios/app-icon.png and /dev/null differ diff --git a/assets/ios/app-icon.svg b/assets/ios/app-icon.svg deleted file mode 100644 index b2a33e3..0000000 --- a/assets/ios/app-icon.svg +++ /dev/null @@ -1,29 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/assets/ios/foreground.png b/assets/ios/foreground.png deleted file mode 100644 index b2540f9..0000000 Binary files a/assets/ios/foreground.png and /dev/null differ diff --git a/assets/ios/foreground.svg b/assets/ios/foreground.svg deleted file mode 100644 index 5598da1..0000000 --- a/assets/ios/foreground.svg +++ /dev/null @@ -1,26 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/assets/macos/app-icon.icns b/assets/macos/app-icon.icns deleted file mode 100644 index 5d93c92..0000000 Binary files a/assets/macos/app-icon.icns and /dev/null differ diff --git a/assets/macos/app-icon.png b/assets/macos/app-icon.png deleted file mode 100644 index 9a21223..0000000 Binary files a/assets/macos/app-icon.png and /dev/null differ diff --git a/assets/macos/app-icon.svg b/assets/macos/app-icon.svg deleted file mode 100644 index f8eb118..0000000 --- a/assets/macos/app-icon.svg +++ /dev/null @@ -1,29 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/assets/macos/foreground.png b/assets/macos/foreground.png deleted file mode 100644 index b2540f9..0000000 Binary files a/assets/macos/foreground.png and /dev/null differ diff --git a/assets/macos/foreground.svg b/assets/macos/foreground.svg deleted file mode 100644 index 5598da1..0000000 --- a/assets/macos/foreground.svg +++ /dev/null @@ -1,26 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/crates/vnidrop/CORE_FLOW.md b/crates/vnidrop/CORE_FLOW.md index 84e31ad..54ea299 100644 --- a/crates/vnidrop/CORE_FLOW.md +++ b/crates/vnidrop/CORE_FLOW.md @@ -51,7 +51,7 @@ bytes through Kotlin memory. - Desktop uses normal filesystem paths. - Android opens SAF/content URIs in Kotlin and passes a borrowed file descriptor; Rust duplicates the descriptor before streaming. -- iOS starts the security-scoped URL lease in Kotlin and keeps it alive while +- iOS starts the security-scoped URL lease in Swift and keeps it alive while Rust streams from the accessible file URL/path. ## Durability And Filesystem Policy diff --git a/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts index 75fa1f5..41dcc83 100644 --- a/desktopApp/build.gradle.kts +++ b/desktopApp/build.gradle.kts @@ -25,7 +25,6 @@ dependencies { implementation(compose.desktop.currentOs) implementation(libs.kotlinx.coroutinesSwing) - implementation(libs.jna) implementation(libs.compose.uiToolingPreview) testImplementation(libs.kotlin.testJunit) @@ -37,16 +36,12 @@ compose.desktop { buildTypes.release.proguard.isEnabled.set(false) nativeDistributions { - targetFormats(TargetFormat.Dmg, TargetFormat.Deb, TargetFormat.Rpm) + targetFormats(TargetFormat.Deb, TargetFormat.Rpm) packageName = "VniDrop" packageVersion = appVersion description = "Send files directly across your devices" vendor = "Sudosy Labs" licenseFile.set(project.file("../LICENSE")) - macOS { - bundleID = "com.vnidrop.app" - iconFile.set(project.file("../assets/macos/app-icon.icns")) - } windows { iconFile.set(project.file("../assets/windows/app-icon.ico")) } diff --git a/desktopApp/src/main/kotlin/com/vnidrop/app/MacOsAppKitAppearance.kt b/desktopApp/src/main/kotlin/com/vnidrop/app/MacOsAppKitAppearance.kt deleted file mode 100644 index 9e3efb8..0000000 --- a/desktopApp/src/main/kotlin/com/vnidrop/app/MacOsAppKitAppearance.kt +++ /dev/null @@ -1,156 +0,0 @@ -package com.vnidrop.app - -import com.sun.jna.Callback -import com.sun.jna.Library -import com.sun.jna.Native -import com.sun.jna.NativeLibrary -import com.sun.jna.Pointer -import com.sun.jna.Structure -import java.io.File - -internal object MacOsAppKitAppearance { - private val objc: ObjCRuntime? by lazy { - runCatching { - NativeLibrary.getInstance("AppKit") - Native.load("objc", ObjCRuntime::class.java) - }.getOrNull() - } - - fun apply(isDarkTheme: Boolean) { - if (!isMacOs()) return - runCatching { - val runtime = objc ?: return - val applicationClass = runtime.objc_getClass("NSApplication") ?: return - val appearanceClass = runtime.objc_getClass("NSAppearance") ?: return - val application = runtime.objc_msgSend(applicationClass, runtime.sel_registerName("sharedApplication")) ?: return - val appearanceName = nsString(runtime, macOsAppearanceName(isDarkTheme)) ?: return - val appearance = runtime.objc_msgSend( - appearanceClass, - runtime.sel_registerName("appearanceNamed:"), - appearanceName, - ) ?: return - runtime.objc_msgSend(application, runtime.sel_registerName("setAppearance:"), appearance) - } - } - - private fun macOsAppearanceName(isDarkTheme: Boolean): String = - if (isDarkTheme) "NSAppearanceNameDarkAqua" else "NSAppearanceNameAqua" - - private fun nsString(runtime: ObjCRuntime, value: String): Pointer? { - val stringClass = runtime.objc_getClass("NSString") ?: return null - return runtime.objc_msgSend(stringClass, runtime.sel_registerName("stringWithUTF8String:"), value) - } - - private fun isMacOs(): Boolean = - System.getProperty("os.name").startsWith("Mac", ignoreCase = true) -} - -internal object MacOsShareSheet { - private val objc: ObjCRuntime? by lazy { - runCatching { - NativeLibrary.getInstance("AppKit") - Native.load("objc", ObjCRuntime::class.java) - }.getOrNull() - } - private var retainedPicker: Pointer? = null - private val systemLibrary: NativeLibrary? by lazy { - runCatching { NativeLibrary.getInstance("System") }.getOrNull() - } - private val dispatch: DispatchRuntime? by lazy { - runCatching { Native.load("System", DispatchRuntime::class.java) }.getOrNull() - } - - fun share(file: File): Result = runCatching { - require(file.isFile) { "The invitation file could not be created" } - var failure: Throwable? = null - val runtime = dispatch ?: error("The macOS main queue is unavailable") - // dispatch_get_main_queue() is a C macro on Darwin, so there is no - // function for dlsym/JNA to resolve. The macro returns this exported - // queue object directly. - val queue = systemLibrary?.getGlobalVariableAddress("_dispatch_main_q") - ?: error("The macOS main queue is unavailable") - runtime.dispatch_sync_f(queue, null, DispatchWork { failure = runCatching { show(file) }.exceptionOrNull() }) - failure?.let { throw it } - } - - private fun show(file: File) { - val runtime = objc ?: error("AppKit is unavailable") - val applicationClass = runtime.objc_getClass("NSApplication") ?: error("NSApplication is unavailable") - val application = runtime.objc_msgSend(applicationClass, runtime.sel_registerName("sharedApplication")) - ?: error("NSApplication could not be opened") - val window = runtime.objc_msgSend(application, runtime.sel_registerName("keyWindow")) - ?: runtime.objc_msgSend(application, runtime.sel_registerName("mainWindow")) - ?: error("No active macOS window") - val contentView = runtime.objc_msgSend(window, runtime.sel_registerName("contentView")) - ?: error("The active window has no content view") - val path = nsString(runtime, file.absolutePath) ?: error("The invitation path is invalid") - val urlClass = runtime.objc_getClass("NSURL") ?: error("NSURL is unavailable") - val url = runtime.objc_msgSend(urlClass, runtime.sel_registerName("fileURLWithPath:"), path) - ?: error("The invitation URL could not be created") - val arrayClass = runtime.objc_getClass("NSArray") ?: error("NSArray is unavailable") - val items = runtime.objc_msgSend(arrayClass, runtime.sel_registerName("arrayWithObject:"), url) - ?: error("The share item could not be created") - val pickerClass = runtime.objc_getClass("NSSharingServicePicker") ?: error("The macOS share sheet is unavailable") - val allocated = runtime.objc_msgSend(pickerClass, runtime.sel_registerName("alloc")) - ?: error("The macOS share sheet could not be allocated") - val picker = runtime.objc_msgSend(allocated, runtime.sel_registerName("initWithItems:"), items) - ?: error("The macOS share sheet could not be created") - retainedPicker?.let { runtime.objc_msgSend(it, runtime.sel_registerName("release")) } - retainedPicker = picker - runtime.objc_msgSend( - picker, - runtime.sel_registerName("showRelativeToRect:ofView:preferredEdge:"), - anchorRect(), - contentView, - 3L, - ) - } - - private fun nsString(runtime: ObjCRuntime, value: String): Pointer? { - val stringClass = runtime.objc_getClass("NSString") ?: return null - return runtime.objc_msgSend(stringClass, runtime.sel_registerName("stringWithUTF8String:"), value) - } - - internal fun validateNativeRectMapping(): Int = anchorRect().size() - internal fun hasNativeMainQueue(): Boolean = - runCatching { systemLibrary?.getGlobalVariableAddress("_dispatch_main_q") != null }.getOrDefault(false) - - private fun anchorRect() = NSRectByValue().apply { - x = 0.0 - y = 0.0 - width = 1.0 - height = 1.0 - write() - } -} - -@Structure.FieldOrder("x", "y", "width", "height") -internal class NSRectByValue : Structure(), Structure.ByValue { - @JvmField var x: Double = 0.0 - @JvmField var y: Double = 0.0 - @JvmField var width: Double = 0.0 - @JvmField var height: Double = 0.0 -} - -private interface ObjCRuntime : Library { - fun objc_getClass(name: String): Pointer? - fun sel_registerName(name: String): Pointer - fun objc_msgSend(receiver: Pointer?, selector: Pointer?): Pointer? - fun objc_msgSend(receiver: Pointer?, selector: Pointer?, argument: Pointer?): Pointer? - fun objc_msgSend(receiver: Pointer?, selector: Pointer?, argument: String): Pointer? - fun objc_msgSend( - receiver: Pointer?, - selector: Pointer?, - rect: NSRectByValue, - view: Pointer?, - edge: Long, - ): Pointer? -} - -private fun interface DispatchWork : Callback { - fun invoke(context: Pointer?) -} - -private interface DispatchRuntime : Library { - fun dispatch_sync_f(queue: Pointer?, context: Pointer?, work: DispatchWork) -} diff --git a/desktopApp/src/main/kotlin/com/vnidrop/app/main.kt b/desktopApp/src/main/kotlin/com/vnidrop/app/main.kt index b9a56b6..fe96cf2 100644 --- a/desktopApp/src/main/kotlin/com/vnidrop/app/main.kt +++ b/desktopApp/src/main/kotlin/com/vnidrop/app/main.kt @@ -47,31 +47,23 @@ import androidx.compose.ui.window.WindowPlacement import androidx.compose.ui.window.WindowScope import androidx.compose.ui.window.application import androidx.compose.ui.window.rememberWindowState -import com.vnidrop.app.platform.DesktopAppearanceBridge -import com.vnidrop.app.feature.send.DesktopShareBridge import com.vnidrop.app.feature.receive.ExternalInvitationController import com.vnidrop.app.feature.receive.MaxVniDropInvitationBytes import com.vnidrop.app.feature.receive.VniDropInvitationExtension import com.vnidrop.app.feature.receive.decodeInvitationBytes +import com.vnidrop.app.platform.DesktopAppearanceBridge import com.vnidrop.app.ui.theme.LocalVniDropColors import java.awt.Desktop import java.io.File fun main(args: Array) { val externalInvitations = ExternalInvitationController() - val macOs = DesktopAppearanceBridge.isMacOs() val linux = DesktopAppearanceBridge.isLinux() - val customWindowChrome = macOs || linux - configureMacOsNativeAppearance() configureInvitationOpenHandler(externalInvitations) args.asSequence() .map(::File) .filter { it.extension.equals(VniDropInvitationExtension, ignoreCase = true) } .forEach { externalInvitations.openFile(it) } - DesktopAppearanceBridge.applyNativeAppearance = MacOsAppKitAppearance::apply - if (macOs) { - DesktopShareBridge.shareFile = MacOsShareSheet::share - } application { val windowState = rememberWindowState() Window( @@ -83,29 +75,21 @@ fun main(args: Array) { ) { App( dependencies = rememberJvmAppDependencies(externalInvitations), - windowChromeTopInset = when { - macOs -> MacOsTitleBarHeight - linux -> LinuxTitleBarHeight - else -> 0.dp - }, - windowContentTopStartRadius = if (customWindowChrome) DesktopContentCornerRadius else 0.dp, - windowChrome = when { - macOs -> { - { MacOsTitleBar() } + windowChromeTopInset = if (linux) LinuxTitleBarHeight else 0.dp, + windowContentTopStartRadius = if (linux) DesktopContentCornerRadius else 0.dp, + windowChrome = if (linux) { + { + LinuxTitleBar( + isMaximized = windowState.placement == WindowPlacement.Maximized, + onMinimize = { windowState.isMinimized = true }, + onToggleMaximize = { + windowState.placement = toggledWindowPlacement(windowState.placement) + }, + onClose = ::exitApplication, + ) } - linux -> { - { - LinuxTitleBar( - isMaximized = windowState.placement == WindowPlacement.Maximized, - onMinimize = { windowState.isMinimized = true }, - onToggleMaximize = { - windowState.placement = toggledWindowPlacement(windowState.placement) - }, - onClose = ::exitApplication, - ) - } - } - else -> null + } else { + null }, ) } @@ -130,55 +114,11 @@ private fun ExternalInvitationController.openFile(file: File) { } } -private fun configureMacOsNativeAppearance() { - if (!DesktopAppearanceBridge.isMacOs()) return - // AWT reads this before creating the first native window. Runtime theme - // changes are handled in the JVM platform appearance hook. - System.setProperty("apple.awt.application.appearance", "system") -} - -private val MacOsTitleBarHeight = 28.dp -private val MacOsTrafficLightsWidth = 76.dp private val LinuxTitleBarHeight = 40.dp private val LinuxWindowControlWidth = 46.dp private val LinuxWindowControlsWidth = 138.dp private val DesktopContentCornerRadius = 20.dp -@Composable -@OptIn(ExperimentalComposeUiApi::class) -private fun WindowScope.MacOsTitleBar() { - val colors = LocalVniDropColors.current - Box( - modifier = Modifier - .fillMaxWidth() - .height(MacOsTitleBarHeight) - .background(colors.backgroundSurface200), - ) { - WindowDraggableArea( - modifier = Modifier - .fillMaxSize() - .padding(start = MacOsTrafficLightsWidth) - .onPointerEvent(PointerEventType.Press) { event -> - if (event.awtEventOrNull?.clickCount == 2) { - DesktopAppearanceBridge.toggleMaximized(window) - } - }, - ) { - Box(modifier = Modifier.fillMaxSize().padding(end = MacOsTrafficLightsWidth)) { - BasicText( - text = "VniDrop", - modifier = Modifier.align(Alignment.Center), - style = TextStyle( - color = colors.foregroundDefault, - fontSize = 13.sp, - fontWeight = FontWeight.SemiBold, - ), - ) - } - } - } -} - @Composable @OptIn(ExperimentalComposeUiApi::class) private fun WindowScope.LinuxTitleBar( diff --git a/desktopApp/src/test/kotlin/com/vnidrop/app/MacOsShareSheetTest.kt b/desktopApp/src/test/kotlin/com/vnidrop/app/MacOsShareSheetTest.kt deleted file mode 100644 index 65b0aaf..0000000 --- a/desktopApp/src/test/kotlin/com/vnidrop/app/MacOsShareSheetTest.kt +++ /dev/null @@ -1,20 +0,0 @@ -package com.vnidrop.app - -import com.vnidrop.app.platform.DesktopAppearanceBridge -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertTrue -import org.junit.Assume.assumeTrue - -class MacOsShareSheetTest { - @Test - fun nativeAnchorRectHasTheCocoaLayout() { - assertEquals(32, MacOsShareSheet.validateNativeRectMapping()) - } - - @Test - fun nativeMainDispatchQueueCanBeResolved() { - assumeTrue(DesktopAppearanceBridge.isMacOs()) - assertTrue(MacOsShareSheet.hasNativeMainQueue()) - } -} diff --git a/gradle.properties b/gradle.properties index 35d3b69..6d5c097 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,7 +1,6 @@ #Kotlin kotlin.code.style=official kotlin.daemon.jvmargs=-Xmx3072M -kotlin.mpp.enableCInteropCommonization=true #Gradle org.gradle.jvmargs=-Xmx4096M -Dfile.encoding=UTF-8 org.gradle.configuration-cache=true diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2fc1758..d7fca50 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -18,7 +18,6 @@ kotlin = "2.4.0" kotlinx-coroutines = "1.11.0" material3 = "1.11.0-alpha07" qrcode = "4.5.0" -jna = "5.17.0" google-code-scanner = "16.1.0" [libraries] @@ -47,7 +46,6 @@ kotlinx-coroutinesCore = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-co kotlinx-coroutinesTest = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" } kotlinx-coroutinesSwing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" } qrcode-kotlin = { module = "io.github.g0dkar:qrcode-kotlin", version.ref = "qrcode" } -jna = { module = "net.java.dev.jna:jna", version.ref = "jna" } google-code-scanner = { module = "com.google.android.gms:play-services-code-scanner", version.ref = "google-code-scanner" } [plugins] diff --git a/iosApp/Configuration/Config.xcconfig b/iosApp/Configuration/Config.xcconfig deleted file mode 100644 index 92bef3e..0000000 --- a/iosApp/Configuration/Config.xcconfig +++ /dev/null @@ -1,7 +0,0 @@ -TEAM_ID= - -PRODUCT_NAME=VniDrop -PRODUCT_BUNDLE_IDENTIFIER=com.vnidrop.app.vnidrop$(TEAM_ID) - -CURRENT_PROJECT_VERSION=1 -MARKETING_VERSION=1.0 diff --git a/iosApp/iosApp.xcodeproj/project.pbxproj b/iosApp/iosApp.xcodeproj/project.pbxproj deleted file mode 100644 index b50b454..0000000 --- a/iosApp/iosApp.xcodeproj/project.pbxproj +++ /dev/null @@ -1,403 +0,0 @@ -// !$*UTF8*$! -{ - archiveVersion = 1; - classes = { - }; - objectVersion = 77; - objects = { - -/* Begin PBXFileReference section */ - FA325F1B4E7D8FFDF19A5C4A /* VniDrop.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = VniDrop.app; sourceTree = BUILT_PRODUCTS_DIR; }; -/* End PBXFileReference section */ - -/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ - 7582E3F916810EEF251FA8C4 /* Exceptions for "iosApp" folder in "iosApp" target */ = { - isa = PBXFileSystemSynchronizedBuildFileExceptionSet; - membershipExceptions = ( - Info.plist, - ); - target = B83017A9EC036A038DC1B430 /* iosApp */; - }; -/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ - -/* Begin PBXFileSystemSynchronizedRootGroup section */ - 244B04A7F91FA6EE0623BFF1 /* iosApp */ = { - isa = PBXFileSystemSynchronizedRootGroup; - exceptions = ( - 7582E3F916810EEF251FA8C4 /* Exceptions for "iosApp" folder in "iosApp" target */, - ); - path = iosApp; - sourceTree = ""; - }; - B93EE283488EDD1107331E67 /* Configuration */ = { - isa = PBXFileSystemSynchronizedRootGroup; - path = Configuration; - sourceTree = ""; - }; -/* End PBXFileSystemSynchronizedRootGroup section */ - -/* Begin PBXFrameworksBuildPhase section */ - 2BC80A7FB47F5EF23FB83738 /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXFrameworksBuildPhase section */ - -/* Begin PBXGroup section */ - CBA2EB46BC3B70D303C46327 = { - isa = PBXGroup; - children = ( - B93EE283488EDD1107331E67 /* Configuration */, - 244B04A7F91FA6EE0623BFF1 /* iosApp */, - C13A067056BA9F38FD87A539 /* Products */, - ); - sourceTree = ""; - }; - C13A067056BA9F38FD87A539 /* Products */ = { - isa = PBXGroup; - children = ( - FA325F1B4E7D8FFDF19A5C4A /* VniDrop.app */, - ); - name = Products; - sourceTree = ""; - }; -/* End PBXGroup section */ - -/* Begin PBXNativeTarget section */ - B83017A9EC036A038DC1B430 /* iosApp */ = { - isa = PBXNativeTarget; - buildConfigurationList = 94A2E337CA60E8CC4A507E7E /* Build configuration list for PBXNativeTarget "iosApp" */; - buildPhases = ( - 091163AA7720AC82BC13CE03 /* Compile Kotlin Framework */, - C8C88FCA7F60AE3F54E85A8C /* Sources */, - 2BC80A7FB47F5EF23FB83738 /* Frameworks */, - 8E81423A1A7357695DF65754 /* Resources */, - ); - buildRules = ( - ); - dependencies = ( - ); - fileSystemSynchronizedGroups = ( - 244B04A7F91FA6EE0623BFF1 /* iosApp */, - ); - name = iosApp; - packageProductDependencies = ( - ); - productName = iosApp; - productReference = FA325F1B4E7D8FFDF19A5C4A /* VniDrop.app */; - productType = "com.apple.product-type.application"; - }; -/* End PBXNativeTarget section */ - -/* Begin PBXProject section */ - F60687F0AEE04DF31E2599D0 /* Project object */ = { - isa = PBXProject; - attributes = { - BuildIndependentTargetsInParallel = 1; - LastSwiftUpdateCheck = 1620; - LastUpgradeCheck = 1620; - TargetAttributes = { - B83017A9EC036A038DC1B430 = { - CreatedOnToolsVersion = 16.2; - }; - }; - }; - buildConfigurationList = FE990A962A8E8AC95D51FA0E /* Build configuration list for PBXProject "iosApp" */; - developmentRegion = en; - hasScannedForEncodings = 0; - knownRegions = ( - en, - Base, - ); - mainGroup = CBA2EB46BC3B70D303C46327; - minimizedProjectReferenceProxies = 1; - preferredProjectObjectVersion = 77; - productRefGroup = C13A067056BA9F38FD87A539 /* Products */; - projectDirPath = ""; - projectRoot = ""; - targets = ( - B83017A9EC036A038DC1B430 /* iosApp */, - ); - }; -/* End PBXProject section */ - -/* Begin PBXResourcesBuildPhase section */ - 8E81423A1A7357695DF65754 /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXResourcesBuildPhase section */ - -/* Begin PBXShellScriptBuildPhase section */ - 091163AA7720AC82BC13CE03 /* Compile Kotlin Framework */ = { - isa = PBXShellScriptBuildPhase; - alwaysOutOfDate = 1; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - ); - inputPaths = ( - ); - name = "Compile Kotlin Framework"; - outputFileListPaths = ( - ); - outputPaths = ( - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - 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 */ - -/* Begin PBXSourcesBuildPhase section */ - C8C88FCA7F60AE3F54E85A8C /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* 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 */; - 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; - ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_TESTABILITY = YES; - ENABLE_USER_SCRIPT_SANDBOXING = NO; - GCC_C_LANGUAGE_STANDARD = gnu17; - GCC_DYNAMIC_NO_PIC = NO; - GCC_NO_COMMON_BLOCKS = YES; - GCC_OPTIMIZATION_LEVEL = 0; - GCC_PREPROCESSOR_DEFINITIONS = ( - "DEBUG=1", - "$(inherited)", - ); - 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 = INCLUDE_SOURCE; - MTL_FAST_MATH = YES; - ONLY_ACTIVE_ARCH = YES; - SDKROOT = iphoneos; - SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - }; - name = Debug; - }; - 87E567D3634C99D02D035476 /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - ARCHS = arm64; - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - CODE_SIGN_ENTITLEMENTS = iosApp/vnidrop.entitlements; - CODE_SIGN_IDENTITY = "Apple Development"; - CODE_SIGN_STYLE = Automatic; - DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\""; - 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; - INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; - INFOPLIST_KEY_UILaunchScreen_Generation = YES; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - OTHER_LDFLAGS = ( - "$(inherited)", - "-framework", - SystemConfiguration, - "-framework", - Network, - "-framework", - CoreNFC, - "-framework", - AVFoundation, - ); - SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; - }; - name = Debug; - }; - A9DAF5A8F7787C0F3BAEC312 /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - ARCHS = arm64; - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - CODE_SIGN_ENTITLEMENTS = iosApp/vnidrop.entitlements; - CODE_SIGN_IDENTITY = "Apple Development"; - CODE_SIGN_STYLE = Automatic; - DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\""; - 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; - INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; - INFOPLIST_KEY_UILaunchScreen_Generation = YES; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - OTHER_LDFLAGS = ( - "$(inherited)", - "-framework", - SystemConfiguration, - "-framework", - Network, - "-framework", - CoreNFC, - "-framework", - AVFoundation, - ); - SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; - }; - name = Release; - }; -/* End XCBuildConfiguration section */ - -/* Begin XCConfigurationList section */ - 94A2E337CA60E8CC4A507E7E /* Build configuration list for PBXNativeTarget "iosApp" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 87E567D3634C99D02D035476 /* Debug */, - A9DAF5A8F7787C0F3BAEC312 /* Release */, - ); - 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.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/iosApp/iosApp.xcodeproj/project.xcworkspace/contents.xcworkspacedata deleted file mode 100644 index fe1aa71..0000000 --- a/iosApp/iosApp.xcodeproj/project.xcworkspace/contents.xcworkspacedata +++ /dev/null @@ -1,7 +0,0 @@ - - - - - \ No newline at end of file diff --git a/iosApp/iosApp/Assets.xcassets/AccentColor.colorset/Contents.json b/iosApp/iosApp/Assets.xcassets/AccentColor.colorset/Contents.json deleted file mode 100644 index 48599ed..0000000 --- a/iosApp/iosApp/Assets.xcassets/AccentColor.colorset/Contents.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "colors": [ - { - "idiom": "universal" - } - ], - "info": { - "author": "xcode", - "version": 1 - } -} diff --git a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/Contents.json b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/Contents.json deleted file mode 100644 index 91523fc..0000000 --- a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/Contents.json +++ /dev/null @@ -1,36 +0,0 @@ -{ - "images" : [ - { - "filename" : "app-icon.png", - "idiom" : "universal", - "platform" : "ios", - "size" : "1024x1024" - }, - { - "appearances" : [ - { - "appearance" : "luminosity", - "value" : "dark" - } - ], - "idiom" : "universal", - "platform" : "ios", - "size" : "1024x1024" - }, - { - "appearances" : [ - { - "appearance" : "luminosity", - "value" : "tinted" - } - ], - "idiom" : "universal", - "platform" : "ios", - "size" : "1024x1024" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/app-icon.png b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/app-icon.png deleted file mode 100644 index 91e521f..0000000 Binary files a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/app-icon.png and /dev/null differ diff --git a/iosApp/iosApp/Assets.xcassets/Contents.json b/iosApp/iosApp/Assets.xcassets/Contents.json deleted file mode 100644 index 73c0059..0000000 --- a/iosApp/iosApp/Assets.xcassets/Contents.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/iosApp/iosApp/ContentView.swift b/iosApp/iosApp/ContentView.swift deleted file mode 100644 index 30fd270..0000000 --- a/iosApp/iosApp/ContentView.swift +++ /dev/null @@ -1,125 +0,0 @@ -import Shared -import SwiftUI -import UIKit - -// Compose renders the app content, but UIKit owns the status bar style. This -// host listens for theme changes from shared Kotlin code and asks iOS to -// recompute the status bar contrast. -final class VniDropHostViewController: UIViewController { - private let composeController: UIViewController - private var usesDarkTheme: Bool - - init(composeController: UIViewController) { - self.composeController = composeController - self.usesDarkTheme = UITraitCollection.current.userInterfaceStyle == .dark - super.init(nibName: nil, bundle: nil) - } - - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override var preferredStatusBarStyle: UIStatusBarStyle { - usesDarkTheme ? .lightContent : .darkContent - } - - override func viewDidLoad() { - super.viewDidLoad() - - addChild(composeController) - view.addSubview(composeController.view) - composeController.view.translatesAutoresizingMaskIntoConstraints = false - NSLayoutConstraint.activate([ - composeController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), - composeController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), - composeController.view.topAnchor.constraint(equalTo: view.topAnchor), - composeController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), - ]) - composeController.didMove(toParent: self) - - NotificationCenter.default.addObserver( - self, - selector: #selector(themeDidChange(_:)), - name: Notification.Name("VniDropThemeChanged"), - object: nil - ) - } - - deinit { - NotificationCenter.default.removeObserver(self) - } - - @objc private func themeDidChange(_ notification: Notification) { - guard let isDark = notification.userInfo?["isDark"] as? String else { return } - usesDarkTheme = isDark == "true" - setNeedsStatusBarAppearanceUpdate() - } -} - -struct ComposeView: UIViewControllerRepresentable { - let externalInvitations: ExternalInvitationController - - func makeUIViewController(context: Self.Context) -> UIViewController { - VniDropHostViewController( - composeController: MainViewControllerKt.MainViewController( - externalInvitations: externalInvitations - ) - ) - } - - func updateUIViewController(_ uiViewController: UIViewController, context: Self.Context) {} -} - -struct ContentView: View { - let externalInvitations: ExternalInvitationController - - var body: some View { - ComposeView(externalInvitations: externalInvitations) - .ignoresSafeArea() - .onOpenURL(perform: openInvitation) - } - - private func openInvitation(_ url: URL) { - guard url.pathExtension.caseInsensitiveCompare("vnd") == .orderedSame else { - externalInvitations.reportOpenFailure(message: "This is not a VniDrop invitation") - return - } - - let hasSecurityAccess = url.startAccessingSecurityScopedResource() - defer { - if hasSecurityAccess { - url.stopAccessingSecurityScopedResource() - } - } - - do { - let values = try url.resourceValues(forKeys: [.fileSizeKey]) - if let fileSize = values.fileSize, fileSize > 65_536 { - throw InvitationOpenError.tooLarge - } - let data = try Data(contentsOf: url, options: .mappedIfSafe) - guard data.count <= 65_536 else { throw InvitationOpenError.tooLarge } - guard let raw = String(data: data, encoding: .utf8) else { - throw InvitationOpenError.invalidEncoding - } - externalInvitations.openInvitation(raw: raw) - } catch { - externalInvitations.reportOpenFailure( - message: (error as? LocalizedError)?.errorDescription ?? "The invitation could not be opened" - ) - } - } -} - -private enum InvitationOpenError: LocalizedError { - case tooLarge - case invalidEncoding - - var errorDescription: String? { - switch self { - case .tooLarge: "The invitation is too large" - case .invalidEncoding: "The invitation is not valid text" - } - } -} diff --git a/iosApp/iosApp/Info.plist b/iosApp/iosApp/Info.plist deleted file mode 100644 index f74686a..0000000 --- a/iosApp/iosApp/Info.plist +++ /dev/null @@ -1,69 +0,0 @@ - - - - - CADisableMinimumFrameDurationOnPhone - - CFBundleDocumentTypes - - - CFBundleTypeName - VniDrop Invitation - CFBundleTypeRole - Viewer - LSHandlerRank - Owner - LSItemContentTypes - - com.vnidrop.app.invitation - - - - UIBackgroundModes - - fetch - processing - remote-notification - - 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 - - - - LSSupportsOpeningDocumentsInPlace - - UIFileSharingEnabled - - NSCameraUsageDescription - VniDrop uses the camera to scan transfer QR codes. - NFCReaderUsageDescription - VniDrop uses NFC to read transfer invitation tags. - UIViewControllerBasedStatusBarAppearance - - NSLocalNetworkUsageDescription - VniDrop needs local network access to send to other local devices if needed. - NSBonjourServices - - - - - diff --git a/iosApp/iosApp/Preview Content/Preview Assets.xcassets/Contents.json b/iosApp/iosApp/Preview Content/Preview Assets.xcassets/Contents.json deleted file mode 100644 index d458f1c..0000000 --- a/iosApp/iosApp/Preview Content/Preview Assets.xcassets/Contents.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "info": { - "author": "xcode", - "version": 1 - } -} diff --git a/iosApp/iosApp/iOSApp.swift b/iosApp/iosApp/iOSApp.swift deleted file mode 100644 index 08210e4..0000000 --- a/iosApp/iosApp/iOSApp.swift +++ /dev/null @@ -1,13 +0,0 @@ -import Shared -import SwiftUI - -@main -struct iOSApp: App { - private let externalInvitations = ExternalInvitationController() - - var body: some Scene { - WindowGroup { - ContentView(externalInvitations: externalInvitations) - } - } -} diff --git a/iosApp/iosApp/vnidrop.entitlements b/iosApp/iosApp/vnidrop.entitlements deleted file mode 100644 index 5f7b942..0000000 --- a/iosApp/iosApp/vnidrop.entitlements +++ /dev/null @@ -1,10 +0,0 @@ - - - - - com.apple.developer.nfc.readersession.formats - - NDEF - - - diff --git a/shared/AGENTS.md b/shared/AGENTS.md index e9c3f10..5428234 100644 --- a/shared/AGENTS.md +++ b/shared/AGENTS.md @@ -7,9 +7,10 @@ still applies; this file wins for UI/KMP work. ## Purpose -`shared` is the multiplatform app layer: Compose UI, feature ViewModels, and -`expect`/`actual` bridges into Android, iOS, and desktop. Native transfer work -goes through UniFFI `VnidropCore` (see `crates/vnidrop`). +`shared` is the Compose Multiplatform app layer for Android, Windows, and Linux: +Compose UI, feature ViewModels, and `expect`/`actual` platform bridges. Native +transfer work goes through UniFFI `VnidropCore` (see `crates/vnidrop`). Apple +platforms use the native SwiftUI app under `apple/`. --- @@ -33,7 +34,7 @@ lists, animation, accessibility: | Theme | Only `LocalVniDropColors` / `VniDropThemeTokens` (`ui/theme/VniDropTheme.kt`). Brand primary light ≈ `#A855F7` (HSL 271, 91%, 65%). | | Strings | CMP composeResources / `Res.string.*` — not Android `R` in `commonMain`. | | DI | Follow existing `AppGraph` construction; no unprompted Hilt/Koin migration. | -| Platform | `androidMain` / `iosMain` / `jvmMain` for pickers, SAF, security-scoped URLs, NFC/QR. | +| Platform | `androidMain` / `jvmMain` for pickers, SAF, NFC/QR, and desktop integration. | | Dependencies | Before adding Jetpack/AndroidX to `commonMain`, verify multiplatform artifacts for all targets. | compose-skill “Existing Project Policy”: adapt to this repo; do not force-migrate. @@ -53,14 +54,12 @@ Optional: ```bash ./gradlew :shared:testAndroidHostTest -./gradlew :shared:iosSimulatorArm64Test ./gradlew :desktopApp:run ./gradlew :androidApp:assembleDebug ``` -CI `:shared:jvmTest` currently runs on **macOS**. Gobley host cargo follows the -current host and architecture, including Linux and Windows; use macOS only when -exact CI parity is required. +CI `:shared:jvmTest` runs on **Linux**. Gobley host cargo follows the current +host and architecture so local desktop builds embed their matching Rust library. When Kotlin changes touch UniFFI-generated APIs, rebuild/test with a full `jvmTest` so Gobley/native pieces stay aligned. @@ -76,7 +75,7 @@ src/ feature/send|receive|approvals|settings|app/ ui/ # theme, components, navigation, feedback, state helpers commonMain/composeResources/ - androidMain|iosMain|jvmMain/ + androidMain|jvmMain/ commonTest|jvmTest|... ``` @@ -86,7 +85,8 @@ src/ documents with relative `displayName` paths before calling Rust (`FileSystemService.android.kt` / `expandShareDirectory`). - **Android receive:** MediaStore Downloads sink and/or SAF tree write sink. -- **iOS:** keep security-scoped leases alive while Rust reads paths. +- **Apple:** lives outside this module under `apple/`; do not add Apple platform + behavior back to KMP. - **Desktop:** filesystem paths; directories may be marked `isDirectory` for Rust walk. Never pass a directory as a single Android FD into `SourceKind.FILE_DESCRIPTOR`. diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index 9ce57a7..ff3f84b 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -1,12 +1,10 @@ @file:OptIn(gobley.gradle.InternalGobleyGradleApi::class) -import gobley.gradle.cargo.dsl.appleMobile import gobley.gradle.cargo.dsl.jvm import gobley.gradle.cargo.tasks.CargoBuildTask import gobley.gradle.cargo.tasks.CargoCheckTask import gobley.gradle.GobleyHost import gobley.gradle.rust.targets.RustAndroidTarget -import gobley.gradle.rust.targets.RustAppleMobileTarget import gobley.gradle.rust.targets.RustTarget import gobley.gradle.Variant import org.gradle.api.DefaultTask @@ -113,18 +111,6 @@ val generateDiagnosticsBuildConfig by tasks.registering { } kotlin { - if (GobleyHost.current.platform == GobleyHost.Platform.MacOS) { - listOf( - iosArm64(), - iosSimulatorArm64() - ).forEach { iosTarget -> - iosTarget.binaries.framework { - baseName = "Shared" - isStatic = true - } - } - } - androidTarget { compilerOptions { jvmTarget = JvmTarget.JVM_11 @@ -190,9 +176,6 @@ android { val hostCargoTargets = buildSet { add(GobleyHost.current.rustTarget) addAll(RustAndroidTarget.entries) - if (GobleyHost.current.platform == GobleyHost.Platform.MacOS) { - addAll(RustAppleMobileTarget.entries) - } } cargo { @@ -207,15 +190,6 @@ cargo { embedRustLibrary.set(rustTarget == GobleyHost.current.rustTarget) } } - builds.appleMobile { - variants { - buildTaskProvider.configure { - if (rustTarget.cinteropName == "ios") { - additionalEnvironment.put("IPHONEOS_DEPLOYMENT_TARGET", "16.0.0") - } - } - } - } builds.configureEach { val buildOnCurrentHost = rustTarget in hostCargoTargets installTargetBeforeBuild.set(buildOnCurrentHost) diff --git a/shared/src/androidMain/kotlin/com/vnidrop/app/core/FileSystemService.android.kt b/shared/src/androidMain/kotlin/com/vnidrop/app/core/FileSystemService.android.kt index 81f9db3..079d36a 100644 --- a/shared/src/androidMain/kotlin/com/vnidrop/app/core/FileSystemService.android.kt +++ b/shared/src/androidMain/kotlin/com/vnidrop/app/core/FileSystemService.android.kt @@ -52,15 +52,13 @@ private class AndroidFileSystemService( ReceiveFolderKind.FileSystemPath -> validatePath(folder.value) ReceiveFolderKind.AndroidPublicDownloads -> validatePublicDownloads() ReceiveFolderKind.AndroidTreeUri -> validateTreeUri(folder.value) - ReceiveFolderKind.IosSecurityScopedUrl -> FolderAccessStatus.Unavailable } override fun createReceiveOutputSink(folder: ReceiveFolder): ReceiveOutputSink? = when (folder.kind) { ReceiveFolderKind.AndroidPublicDownloads -> AndroidMediaStoreDownloadsSink(context) ReceiveFolderKind.AndroidTreeUri -> AndroidTreeReceiveOutputSink(context, folder.value.toUri()) - ReceiveFolderKind.FileSystemPath, - ReceiveFolderKind.IosSecurityScopedUrl -> null + ReceiveFolderKind.FileSystemPath -> null } override suspend fun sharePickedFiles( diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreModels.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreModels.kt index bbff3d3..c3e7e2f 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreModels.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreModels.kt @@ -134,13 +134,6 @@ interface CoreGateway { senderName: String, accessPolicy: ShareAccessPolicy, ): Result - suspend fun shareSecurityScopedFileUrl( - fileUrl: String, - displayName: String, - transferName: String, - senderName: String, - accessPolicy: ShareAccessPolicy, - ): Result /** Multi-source share used by multi-file pickers. */ suspend fun shareSources( sources: List, @@ -151,7 +144,6 @@ interface CoreGateway { suspend fun inspectTicket(ticket: String): Result suspend fun receive(ticket: String, outputDir: String, receiverName: String): Result suspend fun receiveWithOutputSink(ticket: String, outputSink: ReceiveOutputSink, receiverName: String): Result - suspend fun receiveIntoSecurityScopedDirectory(ticket: String, outputDirectoryUrl: String, receiverName: String): Result suspend fun cancel(transferId: ULong): Result suspend fun delete(transferId: ULong): Result suspend fun clearReceiveHistory(): Result diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreRepository.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreRepository.kt index 41a4e6e..8744ef3 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreRepository.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreRepository.kt @@ -113,27 +113,6 @@ class CoreRepository( accessPolicy = accessPolicy, ) - override suspend fun shareSecurityScopedFileUrl( - fileUrl: String, - displayName: String, - transferName: String, - senderName: String, - accessPolicy: ShareAccessPolicy, - ): Result = - shareSources( - sources = listOf( - ShareSource( - kind = SourceKind.IOS_SECURITY_SCOPED_URL, - value = fileUrl, - displayName = displayName.ifBlank { fileUrl.substringAfterLast('/').ifBlank { "transfer" } }, - isDirectory = false, - ), - ), - transferName = transferName, - senderName = senderName, - accessPolicy = accessPolicy, - ) - override suspend fun inspectTicket(ticket: String): Result = runCore { requireCore().inspectTicket(ticket).toModel().also { inspection -> _state.update { it.copy(lastInspection = inspection) } @@ -154,17 +133,6 @@ class CoreRepository( refreshSnapshot() } - override suspend fun receiveIntoSecurityScopedDirectory( - ticket: String, - outputDirectoryUrl: String, - receiverName: String, - ): Result = runCore { - withPlatformPathAccess(SourceKind.IOS_SECURITY_SCOPED_URL, outputDirectoryUrl) { - requireCore().receive(ticket, outputDirectoryUrl, receiverName.ifBlank { null }) - } - refreshSnapshot() - } - override suspend fun cancel(transferId: ULong): Result = runCore { requireCore().cancelTransfer(transferId) refreshSnapshot() diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/core/FilePicker.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/core/FilePicker.kt index badadfe..6203e1d 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/core/FilePicker.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/core/FilePicker.kt @@ -10,9 +10,9 @@ data class PickedShareFile( /** App-owned picker copy that may be deleted after import or when selection is abandoned. */ val isTemporaryCopy: Boolean = false, /** - * When true, [value] is a directory (filesystem path, iOS security-scoped - * folder URL, or Android document tree URI). Platform share code expands or - * walks it; Rust cannot treat an Android FD as a directory. + * When true, [value] is a directory (filesystem path or Android document tree + * URI). Platform share code expands or walks it; Rust cannot treat an Android + * FD as a directory. */ val isDirectory: Boolean = false, ) diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/core/FileSystemService.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/core/FileSystemService.kt index 8616582..5c0cd4b 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/core/FileSystemService.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/core/FileSystemService.kt @@ -8,7 +8,6 @@ enum class ReceiveFolderKind { /** Shared system Downloads via MediaStore (Android 10+). */ AndroidPublicDownloads, AndroidTreeUri, - IosSecurityScopedUrl, } /** Stable token stored in preferences for [ReceiveFolderKind.AndroidPublicDownloads]. */ diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/core/PlatformFileAccess.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/core/PlatformFileAccess.kt index f120814..0e52795 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/core/PlatformFileAccess.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/core/PlatformFileAccess.kt @@ -2,10 +2,8 @@ package com.vnidrop.app.core import uniffi.vnidrop.SourceKind -// Platform file handles have different lifetime rules. Desktop paths need no -// extra work, Rust duplicates Android fd sources immediately, and iOS -// security-scoped URLs must remain leased while Rust performs the blocking -// import/export call. +// Desktop paths need no extra work, while Rust duplicates borrowed Android file +// descriptors immediately before the platform closes them. internal expect suspend fun withPlatformPathAccess( kind: SourceKind, value: String, diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/receive/ReceiveViewModel.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/receive/ReceiveViewModel.kt index c2223b9..c841f2e 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/receive/ReceiveViewModel.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/receive/ReceiveViewModel.kt @@ -161,14 +161,10 @@ class ReceiveViewModel( it.copy(isReceiving = true, lastReceiveError = null, activeReceiveTransferId = null) } val outputSink = fileSystemService.createReceiveOutputSink(folder) - val result = when { - outputSink != null -> repository.receiveWithOutputSink(current.ticket, outputSink, current.receiverName) - folder.kind == ReceiveFolderKind.IosSecurityScopedUrl -> repository.receiveIntoSecurityScopedDirectory( - current.ticket, - folder.value, - current.receiverName, - ) - else -> repository.receive(current.ticket, folder.value, current.receiverName) + val result = if (outputSink != null) { + repository.receiveWithOutputSink(current.ticket, outputSink, current.receiverName) + } else { + repository.receive(current.ticket, folder.value, current.receiverName) } result.fold( onSuccess = { diff --git a/shared/src/commonTest/kotlin/com/vnidrop/app/feature/ViewModelsTest.kt b/shared/src/commonTest/kotlin/com/vnidrop/app/feature/ViewModelsTest.kt index c2843d7..b0a95e7 100644 --- a/shared/src/commonTest/kotlin/com/vnidrop/app/feature/ViewModelsTest.kt +++ b/shared/src/commonTest/kotlin/com/vnidrop/app/feature/ViewModelsTest.kt @@ -217,7 +217,7 @@ class ViewModelsTest { fun settingsUsesDefaultReceiveFolderWhenPlatformDoesNotSupportCustomFolders() = runTest { Dispatchers.setMain(StandardTestDispatcher(testScheduler)) val appDocuments = ReceiveFolder(ReceiveFolderKind.FileSystemPath, "/app/Documents", "Documents") - val externalFolder = ReceiveFolder(ReceiveFolderKind.IosSecurityScopedUrl, "file:///external", "External") + val externalFolder = ReceiveFolder(ReceiveFolderKind.AndroidTreeUri, "content://external", "External") val preferences = preferences().apply { mutablePreferences.value = mutablePreferences.value.copy(receiveFolder = externalFolder) } diff --git a/shared/src/commonTest/kotlin/com/vnidrop/app/support/Fakes.kt b/shared/src/commonTest/kotlin/com/vnidrop/app/support/Fakes.kt index bb4fafc..bc29f64 100644 --- a/shared/src/commonTest/kotlin/com/vnidrop/app/support/Fakes.kt +++ b/shared/src/commonTest/kotlin/com/vnidrop/app/support/Fakes.kt @@ -89,13 +89,6 @@ class FakeCoreGateway : CoreGateway { senderName: String, accessPolicy: ShareAccessPolicy, ) = Result.failure(UnsupportedOperationException()) - override suspend fun shareSecurityScopedFileUrl( - fileUrl: String, - displayName: String, - transferName: String, - senderName: String, - accessPolicy: ShareAccessPolicy, - ) = Result.failure(UnsupportedOperationException()) override suspend fun shareSources( sources: List, transferName: String, @@ -142,13 +135,6 @@ class FakeCoreGateway : CoreGateway { awaitReceiveIfNeeded() return receiveResult } - override suspend fun receiveIntoSecurityScopedDirectory(ticket: String, outputDirectoryUrl: String, receiverName: String): Result { - receiveCount += 1 - lastReceiveTicket = ticket - lastReceiveReceiverName = receiverName - awaitReceiveIfNeeded() - return receiveResult - } override suspend fun cancel(transferId: ULong): Result { cancelledTransfers += transferId return Result.success(Unit) diff --git a/shared/src/iosMain/kotlin/com/vnidrop/app/MainViewController.kt b/shared/src/iosMain/kotlin/com/vnidrop/app/MainViewController.kt deleted file mode 100644 index 830f382..0000000 --- a/shared/src/iosMain/kotlin/com/vnidrop/app/MainViewController.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.vnidrop.app - -import androidx.compose.ui.window.ComposeUIViewController -import com.vnidrop.app.feature.receive.ExternalInvitationController - -fun MainViewController(externalInvitations: ExternalInvitationController) = - ComposeUIViewController { App(rememberIosAppDependencies(externalInvitations)) } diff --git a/shared/src/iosMain/kotlin/com/vnidrop/app/Platform.ios.kt b/shared/src/iosMain/kotlin/com/vnidrop/app/Platform.ios.kt deleted file mode 100644 index 0f592ff..0000000 --- a/shared/src/iosMain/kotlin/com/vnidrop/app/Platform.ios.kt +++ /dev/null @@ -1,57 +0,0 @@ -package com.vnidrop.app - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import com.vnidrop.app.core.rememberFileSystemService -import com.vnidrop.app.notifications.IosLocalNotificationService -import com.vnidrop.app.feature.receive.ExternalInvitationController -import platform.Foundation.NSBundle -import platform.Foundation.NSApplicationSupportDirectory -import platform.Foundation.NSSearchPathForDirectoriesInDomains -import platform.Foundation.NSUserDomainMask -import platform.UIKit.UIDevice - -@Composable -fun rememberIosAppDependencies(externalInvitations: ExternalInvitationController): AppDependencies { - val fileSystemService = rememberFileSystemService() - return remember(fileSystemService) { - val device = UIDevice.currentDevice - AppDependencies( - environment = PlatformEnvironment( - name = device.systemName() + " " + device.systemVersion, - appVersion = NSBundle.mainBundle.objectForInfoDictionaryKey("CFBundleShortVersionString") as? String ?: "0.1.0", - defaultCoreDataDir = iosApplicationDataDirectory(), - defaultUsername = device.name.takeIf(String::isNotBlank) ?: "Receiver", - ), - deviceInfoProvider = IosDeviceInfoProvider(device), - fileSystemService = fileSystemService, - localNotificationService = IosLocalNotificationService(), - externalInvitations = externalInvitations, - ) - } -} - -private fun iosApplicationDataDirectory(): String = - (NSSearchPathForDirectoriesInDomains(NSApplicationSupportDirectory, NSUserDomainMask, true).firstOrNull() as? String) - ?.trimEnd('/')?.plus("/VniDrop") - ?: error("iOS Application Support directory is unavailable") - -private class IosDeviceInfoProvider( - private val device: UIDevice, -) : DeviceInfoProvider { - override suspend fun load(): DeviceInfo = DeviceInfo( - deviceName = device.name, - deviceModel = device.model, - operatingSystem = device.systemName() + " " + device.systemVersion, - network = null, - batteryLevel = runCatching { - val wasMonitoring = device.batteryMonitoringEnabled - try { - device.batteryMonitoringEnabled = true - device.batteryLevel.takeIf { it >= 0.0 }?.let { "${(it * 100).toInt()}%" } - } finally { - device.batteryMonitoringEnabled = wasMonitoring - } - }.getOrNull(), - ) -} diff --git a/shared/src/iosMain/kotlin/com/vnidrop/app/core/FilePicker.ios.kt b/shared/src/iosMain/kotlin/com/vnidrop/app/core/FilePicker.ios.kt deleted file mode 100644 index eb38a85..0000000 --- a/shared/src/iosMain/kotlin/com/vnidrop/app/core/FilePicker.ios.kt +++ /dev/null @@ -1,166 +0,0 @@ -package com.vnidrop.app.core - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import kotlinx.cinterop.ExperimentalForeignApi -import kotlinx.cinterop.readBytes -import platform.Foundation.NSURL -import platform.Foundation.NSFileManager -import platform.Foundation.NSFileSize -import platform.Foundation.NSNumber -import platform.UIKit.UIApplication -import platform.UIKit.UIDocumentPickerDelegateProtocol -import platform.UIKit.UIDocumentPickerViewController -import platform.UIKit.UIDocumentInteractionController -import platform.UIKit.UIImage -import platform.UIKit.UIImagePNGRepresentation -import platform.UIKit.UIModalPresentationFormSheet -import platform.UniformTypeIdentifiers.UTTypeFolder -import platform.UniformTypeIdentifiers.UTTypeItem -import platform.darwin.NSObject - -private var retainedPickerDelegate: DocumentPickerDelegate? = null - -@Composable -actual fun rememberShareFilePicker( - onFilesPicked: (List) -> Unit, - onError: (String) -> Unit, -): ShareFilePicker = remember(onFilesPicked, onError) { - object : ShareFilePicker { - @OptIn(ExperimentalForeignApi::class) - override fun pickFiles() { - val presenter = UIApplication.sharedApplication.keyWindow?.rootViewController - if (presenter == null) { - onError("Could not find an iOS view controller for the document picker") - return - } - - // The composer outlives this callback, so Rust imports a sandbox copy instead of a short-lived provider URL. - val picker = UIDocumentPickerViewController(forOpeningContentTypes = listOf(UTTypeItem), asCopy = true) - picker.allowsMultipleSelection = true - val delegate = DocumentPickerDelegate( - onFilesPicked = onFilesPicked, - onError = onError, - useFileSystemPaths = true, - ) - retainedPickerDelegate = delegate - picker.delegate = delegate - picker.modalPresentationStyle = UIModalPresentationFormSheet - presenter.presentViewController(picker, animated = true, completion = null) - } - - @OptIn(ExperimentalForeignApi::class) - override fun pickFolder() { - val presenter = UIApplication.sharedApplication.keyWindow?.rootViewController - if (presenter == null) { - onError("Could not find an iOS view controller for the folder picker") - return - } - val picker = UIDocumentPickerViewController(forOpeningContentTypes = listOf(UTTypeFolder), asCopy = true) - val delegate = DocumentPickerDelegate( - onFilesPicked = { folders -> - val folder = folders.firstOrNull() ?: return@DocumentPickerDelegate - onFilesPicked( - listOf( - folder.copy(isDirectory = true), - ), - ) - }, - onError = onError, - forceDirectory = true, - useFileSystemPaths = true, - ) - retainedPickerDelegate = delegate - picker.delegate = delegate - picker.modalPresentationStyle = UIModalPresentationFormSheet - presenter.presentViewController(picker, animated = true, completion = null) - } - } -} - -@Composable -actual fun rememberReceiveFolderPicker( - onFolderPicked: (ReceiveFolder) -> Unit, - onError: (String) -> Unit, -): ReceiveFolderPicker = remember(onFolderPicked, onError) { - object : ReceiveFolderPicker { - @OptIn(ExperimentalForeignApi::class) - override fun pickFolder() { - val presenter = UIApplication.sharedApplication.keyWindow?.rootViewController - if (presenter == null) { - onError("Could not find an iOS view controller for the folder picker") - return - } - - val picker = UIDocumentPickerViewController(forOpeningContentTypes = listOf(UTTypeFolder), asCopy = false) - val delegate = DocumentPickerDelegate( - onFilesPicked = { folders -> - val folder = folders.firstOrNull() ?: return@DocumentPickerDelegate - onFolderPicked( - ReceiveFolder( - kind = ReceiveFolderKind.IosSecurityScopedUrl, - value = folder.value, - displayName = folder.displayName, - ), - ) - }, - onError = onError, - ) - retainedPickerDelegate = delegate - picker.delegate = delegate - picker.modalPresentationStyle = UIModalPresentationFormSheet - presenter.presentViewController(picker, animated = true, completion = null) - } - } -} - -private class DocumentPickerDelegate( - private val onFilesPicked: (List) -> Unit, - private val onError: (String) -> Unit, - private val forceDirectory: Boolean = false, - private val useFileSystemPaths: Boolean = false, -) : NSObject(), UIDocumentPickerDelegateProtocol { - override fun documentPicker(controller: UIDocumentPickerViewController, didPickDocumentsAtURLs: List<*>) { - val files = didPickDocumentsAtURLs.mapNotNull { raw -> - val url = raw as? NSURL ?: return@mapNotNull null - val displayName = url.lastPathComponent ?: "transfer" - val didStartAccess = url.startAccessingSecurityScopedResource() - val sizeBytes = try { - if (forceDirectory) { - null - } else { - val attributes = url.path?.let { NSFileManager.defaultManager.attributesOfItemAtPath(it, null) } - (attributes?.get(NSFileSize) as? NSNumber)?.unsignedLongLongValue - } - } finally { - if (didStartAccess) url.stopAccessingSecurityScopedResource() - } - PickedShareFile( - if (useFileSystemPaths) url.path.orEmpty() else url.absoluteString ?: url.path.orEmpty(), - displayName, - sizeBytes, - nativeFileIcon(url), - isTemporaryCopy = useFileSystemPaths, - isDirectory = forceDirectory, - ) - } - if (files.isEmpty()) { - onError("The selected iOS document URL was invalid") - } else { - onFilesPicked(files) - } - retainedPickerDelegate = null - } - - override fun documentPickerWasCancelled(controller: UIDocumentPickerViewController) { - retainedPickerDelegate = null - } -} - -@OptIn(ExperimentalForeignApi::class) -private fun nativeFileIcon(url: NSURL): ByteArray? = runCatching { - val controller = UIDocumentInteractionController.interactionControllerWithURL(url) - val icon = controller.icons.lastOrNull() as? UIImage ?: return null - val data = UIImagePNGRepresentation(icon) ?: return null - data.bytes?.readBytes(data.length.toInt()) -}.getOrNull() diff --git a/shared/src/iosMain/kotlin/com/vnidrop/app/core/FileSystemService.ios.kt b/shared/src/iosMain/kotlin/com/vnidrop/app/core/FileSystemService.ios.kt deleted file mode 100644 index f350fb3..0000000 --- a/shared/src/iosMain/kotlin/com/vnidrop/app/core/FileSystemService.ios.kt +++ /dev/null @@ -1,116 +0,0 @@ -package com.vnidrop.app.core - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import kotlinx.coroutines.suspendCancellableCoroutine -import platform.Foundation.NSFileManager -import platform.Foundation.NSDocumentDirectory -import platform.Foundation.NSSearchPathForDirectoriesInDomains -import platform.Foundation.NSURL -import platform.Foundation.NSUserDomainMask -import platform.UIKit.UIApplication -import uniffi.vnidrop.ReceiveOutputSink -import uniffi.vnidrop.SourceKind -import kotlin.coroutines.resume - -@Composable -actual fun rememberFileSystemService(): FileSystemService = - remember { IosFileSystemService() } - -private class IosFileSystemService : FileSystemService { - // App-owned Documents remains durable across launches; raw external picker URLs do not. - override val supportsCustomReceiveFolders: Boolean = false - - override fun defaultReceiveFolder(): ReceiveFolder { - val path = NSSearchPathForDirectoriesInDomains( - NSDocumentDirectory, - NSUserDomainMask, - true, - ).firstOrNull() as? String ?: "" - return ReceiveFolder( - kind = ReceiveFolderKind.FileSystemPath, - value = path, - displayName = "Documents", - ) - } - - override suspend fun validateReceiveFolder(folder: ReceiveFolder): FolderAccessStatus = - when (folder.kind) { - ReceiveFolderKind.FileSystemPath -> { - if (NSFileManager.defaultManager.isWritableFileAtPath(folder.value)) { - FolderAccessStatus.Writable - } else { - FolderAccessStatus.Unavailable - } - } - ReceiveFolderKind.IosSecurityScopedUrl -> validateSecurityScopedUrl(folder.value) - ReceiveFolderKind.AndroidTreeUri, - ReceiveFolderKind.AndroidPublicDownloads -> FolderAccessStatus.Unavailable - } - - override fun createReceiveOutputSink(folder: ReceiveFolder): ReceiveOutputSink? = null - - override suspend fun discardPickedFiles(files: List) { - files.asSequence() - .filter(PickedShareFile::isTemporaryCopy) - .map(PickedShareFile::value) - .distinct() - .forEach { path -> NSFileManager.defaultManager.removeItemAtPath(path, null) } - } - - override fun canRevealReceiveFolder(folder: ReceiveFolder): Boolean = - folder.kind == ReceiveFolderKind.FileSystemPath && - folder.value.trimEnd('/') == defaultReceiveFolder().value.trimEnd('/') - - override suspend fun revealReceiveFolder(folder: ReceiveFolder): Result { - if (!canRevealReceiveFolder(folder)) { - return Result.failure(IllegalArgumentException("The receive folder is not VniDrop Documents")) - } - // Files can reveal app-owned Documents after the sharing keys in Info.plist are enabled. - val url = NSURL.URLWithString("shareddocuments://${folder.value}") - ?: return Result.failure(IllegalStateException("The Files location URL is unavailable")) - val opened = suspendCancellableCoroutine { continuation -> - UIApplication.sharedApplication.openURL(url, emptyMap()) { success -> - if (continuation.isActive) continuation.resume(success) - } - } - return if (opened) { - Result.success(Unit) - } else { - Result.failure(IllegalStateException("Could not open VniDrop Documents in Files")) - } - } - - override suspend fun sharePickedFiles( - repository: CoreGateway, - files: List, - transferName: String, - senderName: String, - accessPolicy: ShareAccessPolicy, - ): Result { - require(files.isNotEmpty()) { "Select at least one file to share" } - return repository.shareSources(files.map(PickedShareFile::toIosShareSource), transferName, senderName, accessPolicy) - } - - private fun validateSecurityScopedUrl(value: String): FolderAccessStatus { - val url = NSURL.URLWithString(value) ?: NSURL.fileURLWithPath(value) - val didStartAccess = url.startAccessingSecurityScopedResource() - return try { - val path = url.path - if (path != null && NSFileManager.defaultManager.isWritableFileAtPath(path)) { - FolderAccessStatus.Writable - } else { - FolderAccessStatus.PermissionRequired - } - } finally { - if (didStartAccess) url.stopAccessingSecurityScopedResource() - } - } -} - -internal fun PickedShareFile.toIosShareSource() = uniffi.vnidrop.ShareSource( - kind = SourceKind.PATH, - value = value, - displayName = displayName, - isDirectory = isDirectory, -) diff --git a/shared/src/iosMain/kotlin/com/vnidrop/app/core/PlatformFileAccess.ios.kt b/shared/src/iosMain/kotlin/com/vnidrop/app/core/PlatformFileAccess.ios.kt deleted file mode 100644 index f29dd1b..0000000 --- a/shared/src/iosMain/kotlin/com/vnidrop/app/core/PlatformFileAccess.ios.kt +++ /dev/null @@ -1,24 +0,0 @@ -package com.vnidrop.app.core - -import platform.Foundation.NSURL -import uniffi.vnidrop.SourceKind - -internal actual suspend fun withPlatformPathAccess( - kind: SourceKind, - value: String, - block: suspend () -> T, -): T { - if (kind != SourceKind.IOS_SECURITY_SCOPED_URL) { - return block() - } - - val url = NSURL.URLWithString(value) ?: NSURL.fileURLWithPath(value) - val didStartAccess = url.startAccessingSecurityScopedResource() - return try { - block() - } finally { - if (didStartAccess) { - url.stopAccessingSecurityScopedResource() - } - } -} diff --git a/shared/src/iosMain/kotlin/com/vnidrop/app/diagnostics/PendingCrashStore.ios.kt b/shared/src/iosMain/kotlin/com/vnidrop/app/diagnostics/PendingCrashStore.ios.kt deleted file mode 100644 index a743197..0000000 --- a/shared/src/iosMain/kotlin/com/vnidrop/app/diagnostics/PendingCrashStore.ios.kt +++ /dev/null @@ -1,99 +0,0 @@ -package com.vnidrop.app.diagnostics - -import kotlinx.cinterop.BetaInteropApi -import kotlinx.cinterop.ExperimentalForeignApi -import kotlinx.cinterop.addressOf -import kotlinx.cinterop.convert -import kotlinx.cinterop.usePinned -import platform.Foundation.NSData -import platform.Foundation.NSFileManager -import platform.Foundation.create -import platform.Foundation.dataWithContentsOfFile -import platform.Foundation.writeToFile -import platform.posix.memcpy - -actual fun createPendingCrashStore(appDataDir: String): PendingCrashStore = - IosPendingCrashStore(appDataDir) - -@OptIn(ExperimentalForeignApi::class) -private class IosPendingCrashStore( - appDataDir: String, -) : PendingCrashStore { - private val fileManager = NSFileManager.defaultManager - private val directory = appDataDir.trimEnd('/') + "/diagnostics/crashes" - - override fun write(report: CrashReport) { - if (!isValidDiagnosticId(report.id)) return - ensureDirectory() - val path = "$directory/${report.id}.crash" - val payload = CrashReportCodec.encode(report) - val data = payload.encodeToByteArray().toNSData() - data.writeToFile(path, atomically = true) - } - - override fun list(): List { - ensureDirectory() - val names = fileManager.contentsOfDirectoryAtPath(directory, null).orEmpty() - .filterIsInstance() - .filter { it.endsWith(".crash") } - return names.mapNotNull { name -> - val path = "$directory/$name" - val data = NSData.dataWithContentsOfFile(path) ?: return@mapNotNull null - val text = data.toUtf8String() - CrashReportCodec.decode(text) - }.sortedByDescending { it.timestampMillis } - } - - override fun delete(id: String) { - if (!isValidDiagnosticId(id)) return - fileManager.removeItemAtPath("$directory/$id.crash", null) - } - - override fun prune(olderThanTimestampMillis: Long, maxCount: Int) { - require(maxCount > 0) { "maxCount must be positive" } - ensureDirectory() - val reports = fileManager.contentsOfDirectoryAtPath(directory, null).orEmpty() - .filterIsInstance() - .filter { it.endsWith(".crash") } - .mapNotNull { name -> - val path = "$directory/$name" - val report = NSData.dataWithContentsOfFile(path) - ?.toUtf8String() - ?.let(CrashReportCodec::decode) - if (report == null) { - fileManager.removeItemAtPath(path, null) - null - } else { - name to report - } - } - .sortedByDescending { (_, report) -> report.timestampMillis } - reports.forEachIndexed { index, (name, report) -> - if (index >= maxCount || report.timestampMillis < olderThanTimestampMillis) { - fileManager.removeItemAtPath("$directory/$name", null) - } - } - } - - private fun ensureDirectory() { - fileManager.createDirectoryAtPath(directory, withIntermediateDirectories = true, attributes = null, error = null) - } -} - -@OptIn(ExperimentalForeignApi::class, BetaInteropApi::class) -private fun ByteArray.toNSData(): NSData = - usePinned { pinned -> - NSData.create(bytes = pinned.addressOf(0), length = size.toULong()) - } - -@OptIn(ExperimentalForeignApi::class) -private fun NSData.toUtf8String(): String { - val size = length.toInt() - if (size == 0) return "" - val result = ByteArray(size) - val source = bytes ?: return "" - result.usePinned { pinned -> - memcpy(pinned.addressOf(0), source, size.convert()) - } - return result.decodeToString() -} diff --git a/shared/src/iosMain/kotlin/com/vnidrop/app/diagnostics/PlatformCrashHook.ios.kt b/shared/src/iosMain/kotlin/com/vnidrop/app/diagnostics/PlatformCrashHook.ios.kt deleted file mode 100644 index 49d4127..0000000 --- a/shared/src/iosMain/kotlin/com/vnidrop/app/diagnostics/PlatformCrashHook.ios.kt +++ /dev/null @@ -1,16 +0,0 @@ -package com.vnidrop.app.diagnostics - -import kotlin.experimental.ExperimentalNativeApi - -@OptIn(ExperimentalNativeApi::class) -actual fun installPlatformCrashHook(onCrash: (Throwable) -> Unit) { - val previous = setUnhandledExceptionHook { throwable -> - runCatching { onCrash(throwable) } - // Terminate like the default hook after capture. - terminateWithUnhandledException(throwable) - } - // Keep a reference so the previous hook is not GC'd unused; we intentionally - // replace the default with capture-then-terminate. - @Suppress("UNUSED_VARIABLE") - val ignored = previous -} diff --git a/shared/src/iosMain/kotlin/com/vnidrop/app/diagnostics/PlatformHttp.ios.kt b/shared/src/iosMain/kotlin/com/vnidrop/app/diagnostics/PlatformHttp.ios.kt deleted file mode 100644 index 9ba696c..0000000 --- a/shared/src/iosMain/kotlin/com/vnidrop/app/diagnostics/PlatformHttp.ios.kt +++ /dev/null @@ -1,73 +0,0 @@ -package com.vnidrop.app.diagnostics - -import kotlinx.cinterop.BetaInteropApi -import kotlinx.cinterop.ExperimentalForeignApi -import kotlinx.cinterop.addressOf -import kotlinx.cinterop.convert -import kotlinx.cinterop.usePinned -import kotlinx.coroutines.suspendCancellableCoroutine -import platform.Foundation.NSData -import platform.Foundation.NSHTTPURLResponse -import platform.Foundation.NSMutableURLRequest -import platform.Foundation.NSURL -import platform.Foundation.NSURLSession -import platform.Foundation.create -import platform.Foundation.dataTaskWithRequest -import platform.Foundation.setHTTPBody -import platform.Foundation.setHTTPMethod -import platform.Foundation.setValue -import platform.posix.memcpy -import kotlin.coroutines.resume - -@OptIn(ExperimentalForeignApi::class) -actual suspend fun platformHttpPost( - url: String, - headers: Map, - bodyUtf8: String, -): PlatformHttpResponse = suspendCancellableCoroutine { cont -> - val nsUrl = NSURL.URLWithString(url) - if (nsUrl == null) { - cont.resume(PlatformHttpResponse(statusCode = 0, body = "invalid_url")) - return@suspendCancellableCoroutine - } - val request = NSMutableURLRequest.requestWithURL(nsUrl).apply { - setHTTPMethod("POST") - setValue("application/json; charset=utf-8", forHTTPHeaderField = "Content-Type") - headers.forEach { (key, value) -> - setValue(value, forHTTPHeaderField = key) - } - setHTTPBody(bodyUtf8.encodeToByteArray().toNSData()) - } - val task = NSURLSession.sharedSession.dataTaskWithRequest(request) { data, response, error -> - if (!cont.isActive) return@dataTaskWithRequest - if (error != null) { - val message = error.localizedDescription - cont.resume(PlatformHttpResponse(statusCode = 0, body = message)) - return@dataTaskWithRequest - } - val http = response as? NSHTTPURLResponse - val status = http?.statusCode?.toInt() ?: 0 - val body = data?.toUtf8String().orEmpty() - cont.resume(PlatformHttpResponse(statusCode = status, body = body)) - } - cont.invokeOnCancellation { task.cancel() } - task.resume() -} - -@OptIn(ExperimentalForeignApi::class, BetaInteropApi::class) -private fun ByteArray.toNSData(): NSData = - usePinned { pinned -> - NSData.create(bytes = pinned.addressOf(0), length = size.toULong()) - } - -@OptIn(ExperimentalForeignApi::class) -private fun NSData.toUtf8String(): String { - val size = length.toInt() - if (size == 0) return "" - val result = ByteArray(size) - val source = bytes ?: return "" - result.usePinned { pinned -> - memcpy(pinned.addressOf(0), source, size.convert()) - } - return result.decodeToString() -} diff --git a/shared/src/iosMain/kotlin/com/vnidrop/app/feature/receive/ReceiveInvitationActions.ios.kt b/shared/src/iosMain/kotlin/com/vnidrop/app/feature/receive/ReceiveInvitationActions.ios.kt deleted file mode 100644 index c435555..0000000 --- a/shared/src/iosMain/kotlin/com/vnidrop/app/feature/receive/ReceiveInvitationActions.ios.kt +++ /dev/null @@ -1,384 +0,0 @@ -package com.vnidrop.app.feature.receive - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import kotlinx.cinterop.BetaInteropApi -import kotlinx.cinterop.ExperimentalForeignApi -import kotlinx.cinterop.ObjCAction -import kotlinx.cinterop.ObjCObjectVar -import kotlinx.cinterop.alloc -import kotlinx.cinterop.memScoped -import kotlinx.cinterop.ptr -import kotlinx.cinterop.readBytes -import kotlinx.cinterop.value -import platform.AVFoundation.AVAuthorizationStatusAuthorized -import platform.AVFoundation.AVAuthorizationStatusDenied -import platform.AVFoundation.AVAuthorizationStatusNotDetermined -import platform.AVFoundation.AVAuthorizationStatusRestricted -import platform.AVFoundation.AVCaptureDevice -import platform.AVFoundation.AVCaptureDeviceInput -import platform.AVFoundation.AVCaptureMetadataOutput -import platform.AVFoundation.AVCaptureMetadataOutputObjectsDelegateProtocol -import platform.AVFoundation.AVCaptureOutput -import platform.AVFoundation.AVCaptureConnection -import platform.AVFoundation.AVCaptureSession -import platform.AVFoundation.AVCaptureSessionPresetHigh -import platform.AVFoundation.AVCaptureVideoPreviewLayer -import platform.AVFoundation.AVLayerVideoGravityResizeAspectFill -import platform.AVFoundation.AVMediaTypeVideo -import platform.AVFoundation.AVMetadataMachineReadableCodeObject -import platform.AVFoundation.AVMetadataObjectTypeQRCode -import platform.AVFoundation.authorizationStatusForMediaType -import platform.AVFoundation.requestAccessForMediaType -import platform.CoreGraphics.CGRectMake -import platform.CoreNFC.NFCNDEFMessage -import platform.CoreNFC.NFCNDEFPayload -import platform.CoreNFC.NFCNDEFReaderSession -import platform.CoreNFC.NFCNDEFReaderSessionDelegateProtocol -import platform.CoreNFC.NFCTypeNameFormatMedia -import platform.Foundation.NSData -import platform.Foundation.NSError -import platform.Foundation.NSFileManager -import platform.Foundation.NSURL -import platform.UIKit.NSTextAlignmentCenter -import platform.UIKit.UIApplication -import platform.UIKit.UIButton -import platform.UIKit.UIButtonTypeSystem -import platform.UIKit.UIColor -import platform.UIKit.UIControlEventTouchUpInside -import platform.UIKit.UIControlStateNormal -import platform.UIKit.UIDocumentPickerDelegateProtocol -import platform.UIKit.UIDocumentPickerViewController -import platform.UIKit.UILabel -import platform.UIKit.UIModalPresentationFormSheet -import platform.UIKit.UIModalPresentationFullScreen -import platform.UIKit.UIViewAutoresizingFlexibleHeight -import platform.UIKit.UIViewAutoresizingFlexibleWidth -import platform.UIKit.UIViewController -import platform.UniformTypeIdentifiers.UTTypeData -import platform.darwin.DISPATCH_QUEUE_PRIORITY_DEFAULT -import platform.darwin.NSObject -import platform.darwin.dispatch_async -import platform.darwin.dispatch_get_global_queue -import platform.darwin.dispatch_get_main_queue - -private var retainedInvitationDelegate: InvitationDocumentDelegate? = null -private var retainedQrScanner: QrScannerViewController? = null -private var retainedNfcReader: InvitationNfcReader? = null - -@Composable -actual fun rememberReceiveInvitationActions(): ReceiveInvitationActions = remember { - object : ReceiveInvitationActions { - override val fileAvailability = ReceiveMethodAvailability.Available - override val qrAvailability = - if (AVCaptureDevice.defaultDeviceWithMediaType(AVMediaTypeVideo) != null) { - ReceiveMethodAvailability.Available - } else { - ReceiveMethodAvailability.Unavailable - } - override val nfcAvailability = - if (NFCNDEFReaderSession.readingAvailable) { - ReceiveMethodAvailability.Available - } else { - ReceiveMethodAvailability.Unavailable - } - - @OptIn(ExperimentalForeignApi::class) - override fun pickInvitation(onResult: (Result) -> Unit) { - cancel() - val presenter = topPresenter() - ?: return onResult(Result.failure(IllegalStateException("Could not find an iOS view controller"))) - val picker = UIDocumentPickerViewController(forOpeningContentTypes = listOf(UTTypeData), asCopy = true) - val delegate = InvitationDocumentDelegate(onResult) - retainedInvitationDelegate = delegate - picker.delegate = delegate - picker.modalPresentationStyle = UIModalPresentationFormSheet - presenter.presentViewController(picker, animated = true, completion = null) - } - - override fun scanQrCode(onResult: (Result) -> Unit) { - cancel() - val presenter = topPresenter() - ?: return onResult(Result.failure(IllegalStateException("Could not find an iOS view controller"))) - ensureCameraAccess { granted -> - if (!granted) { - onResult(Result.failure(IllegalStateException("Camera access is required to scan QR codes"))) - return@ensureCameraAccess - } - val scanner = QrScannerViewController { result -> - retainedQrScanner = null - onResult(result) - } - retainedQrScanner = scanner - scanner.modalPresentationStyle = UIModalPresentationFullScreen - presenter.presentViewController(scanner, animated = true, completion = null) - } - } - - override fun readNfcInvitation(onResult: (Result) -> Unit) { - cancel() - if (!NFCNDEFReaderSession.readingAvailable) { - onResult(Result.failure(UnsupportedOperationException("NFC reading is unavailable on this device"))) - return - } - val reader = InvitationNfcReader { result -> - retainedNfcReader = null - onResult(result) - } - retainedNfcReader = reader - reader.start() - } - - override fun cancel() { - retainedNfcReader?.cancel() - retainedNfcReader = null - retainedQrScanner?.cancelScan() - retainedQrScanner = null - retainedInvitationDelegate = null - } - } -} - -private fun topPresenter(): UIViewController? { - var controller = UIApplication.sharedApplication.keyWindow?.rootViewController - while (controller?.presentedViewController != null) { - controller = controller?.presentedViewController - } - return controller -} - -private fun ensureCameraAccess(onResult: (Boolean) -> Unit) { - when (AVCaptureDevice.authorizationStatusForMediaType(AVMediaTypeVideo)) { - AVAuthorizationStatusAuthorized -> onResult(true) - AVAuthorizationStatusNotDetermined -> { - AVCaptureDevice.requestAccessForMediaType(AVMediaTypeVideo) { granted -> - dispatch_async(dispatch_get_main_queue()) { onResult(granted) } - } - } - AVAuthorizationStatusDenied, AVAuthorizationStatusRestricted -> onResult(false) - else -> onResult(false) - } -} - -private class InvitationDocumentDelegate( - private val onResult: (Result) -> Unit, -) : NSObject(), UIDocumentPickerDelegateProtocol { - @OptIn(ExperimentalForeignApi::class) - override fun documentPicker(controller: UIDocumentPickerViewController, didPickDocumentsAtURLs: List<*>) { - onResult(runCatching { - val url = didPickDocumentsAtURLs.firstOrNull() as? NSURL - ?: error("The selected invitation URL was invalid") - val path = url.path ?: error("The invitation path was invalid") - val data = NSFileManager.defaultManager.contentsAtPath(path) ?: error("The invitation could not be opened") - val length = data.length.toInt() - require(length <= MaxInvitationBytes) { "The invitation is too large" } - val bytes = data.bytes?.readBytes(length) ?: error("The invitation is empty") - decodeInvitationBytes(bytes) - }) - retainedInvitationDelegate = null - } - - override fun documentPickerWasCancelled(controller: UIDocumentPickerViewController) { - retainedInvitationDelegate = null - } -} - -@OptIn(ExperimentalForeignApi::class, BetaInteropApi::class) -private class QrScannerViewController( - private val onResult: (Result) -> Unit, -) : UIViewController(nibName = null, bundle = null), AVCaptureMetadataOutputObjectsDelegateProtocol { - private val session = AVCaptureSession() - private var previewLayer: AVCaptureVideoPreviewLayer? = null - private var finished = false - private val closeTarget = ButtonTarget { cancelScan() } - - override fun viewDidLoad() { - super.viewDidLoad() - view.backgroundColor = UIColor.blackColor - - val hint = UILabel(frame = view.bounds).apply { - text = "Point the camera at a VniDrop QR code" - textColor = UIColor.whiteColor - textAlignment = NSTextAlignmentCenter - numberOfLines = 0 - autoresizingMask = UIViewAutoresizingFlexibleWidth or UIViewAutoresizingFlexibleHeight - } - view.addSubview(hint) - - val close = UIButton.buttonWithType(UIButtonTypeSystem).apply { - setTitle("Cancel", forState = UIControlStateNormal) - setTitleColor(UIColor.whiteColor, forState = UIControlStateNormal) - addTarget(closeTarget, platform.objc.sel_registerName("invoke"), UIControlEventTouchUpInside) - setFrame(CGRectMake(16.0, 52.0, 88.0, 36.0)) - } - view.addSubview(close) - configureSession() - } - - override fun viewDidLayoutSubviews() { - super.viewDidLayoutSubviews() - previewLayer?.setFrame(view.bounds) - } - - override fun viewWillDisappear(animated: Boolean) { - super.viewWillDisappear(animated) - if (session.running) session.stopRunning() - } - - fun cancelScan() { - finish(Result.failure(IllegalStateException("QR scanning was cancelled"))) - } - - private fun configureSession() { - val device = AVCaptureDevice.defaultDeviceWithMediaType(AVMediaTypeVideo) - ?: return finish(Result.failure(IllegalStateException("No camera is available"))) - - memScoped { - val errorPtr = alloc>() - val input = AVCaptureDeviceInput.deviceInputWithDevice(device, errorPtr.ptr) - if (input == null) { - finish( - Result.failure( - IllegalStateException(errorPtr.value?.localizedDescription ?: "Could not open the camera"), - ), - ) - return - } - if (!session.canAddInput(input)) { - finish(Result.failure(IllegalStateException("Could not configure the camera input"))) - return - } - session.addInput(input) - } - - val output = AVCaptureMetadataOutput() - if (!session.canAddOutput(output)) { - finish(Result.failure(IllegalStateException("Could not configure the QR scanner"))) - return - } - session.addOutput(output) - output.setMetadataObjectsDelegate(this, queue = dispatch_get_main_queue()) - output.metadataObjectTypes = listOf(AVMetadataObjectTypeQRCode) - - val layer = AVCaptureVideoPreviewLayer(session = session).apply { - videoGravity = AVLayerVideoGravityResizeAspectFill - setFrame(view.bounds) - } - view.layer.insertSublayer(layer, atIndex = 0u) - previewLayer = layer - session.sessionPreset = AVCaptureSessionPresetHigh - - dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT.toLong(), 0u)) { - session.startRunning() - } - } - - override fun captureOutput( - output: AVCaptureOutput, - didOutputMetadataObjects: List<*>, - fromConnection: AVCaptureConnection, - ) { - val code = didOutputMetadataObjects - .mapNotNull { it as? AVMetadataMachineReadableCodeObject } - .firstOrNull { it.type == AVMetadataObjectTypeQRCode } - val value = code?.stringValue?.trim().orEmpty() - if (value.isNotEmpty()) { - finish(Result.success(value)) - } - } - - private fun finish(result: Result) { - if (finished) return - finished = true - if (session.running) session.stopRunning() - if (presentingViewController != null) { - dismissViewControllerAnimated(true) { onResult(result) } - } else { - onResult(result) - } - } -} - -@OptIn(BetaInteropApi::class) -private class ButtonTarget( - private val onClick: () -> Unit, -) : NSObject() { - @ObjCAction - fun invoke() { - onClick() - } -} - -@OptIn(ExperimentalForeignApi::class) -private class InvitationNfcReader( - private val onResult: (Result) -> Unit, -) : NSObject(), NFCNDEFReaderSessionDelegateProtocol { - private var session: NFCNDEFReaderSession? = null - private var finished = false - - fun start() { - val reader = NFCNDEFReaderSession(this, dispatch_get_main_queue(), invalidateAfterFirstRead = true) - reader.alertMessage = "Hold your iPhone near a VniDrop invitation tag" - session = reader - reader.beginSession() - } - - fun cancel() { - session?.invalidateSession() - session = null - } - - override fun readerSession(session: NFCNDEFReaderSession, didInvalidateWithError: NSError) { - if (finished) return - // NFCReaderError.readerSessionInvalidationErrorUserCanceled == 200 - val cancelled = didInvalidateWithError.code == 200L - finish( - if (cancelled) { - Result.failure(IllegalStateException("NFC reading was cancelled")) - } else { - Result.failure( - IllegalStateException(didInvalidateWithError.localizedDescription ?: "NFC reading failed"), - ) - }, - ) - } - - override fun readerSession(session: NFCNDEFReaderSession, didDetectNDEFs: List<*>) { - val ticket = runCatching { - val messages = didDetectNDEFs.mapNotNull { it as? NFCNDEFMessage } - messages - .flatMap { message -> message.records.mapNotNull { it as? NFCNDEFPayload } } - .firstNotNullOfOrNull(::payloadAsInvitation) - ?: error("This NFC tag does not contain a VniDrop invitation") - } - session.invalidateSession() - finish(ticket) - } - - private fun finish(result: Result) { - if (finished) return - finished = true - session = null - dispatch_async(dispatch_get_main_queue()) { onResult(result) } - } -} - -@OptIn(ExperimentalForeignApi::class) -private fun payloadAsInvitation(payload: NFCNDEFPayload): String? { - val type = payload.type?.toByteArray()?.decodeToString() ?: return null - val data = payload.payload?.toByteArray() ?: return null - return when { - payload.typeNameFormat == NFCTypeNameFormatMedia && type == InvitationMimeType -> - decodeInvitationBytes(data) - payload.typeNameFormat == NFCTypeNameFormatMedia && type.startsWith("text/") -> - decodeInvitationBytes(data) - else -> null - } -} - -@OptIn(ExperimentalForeignApi::class) -private fun NSData.toByteArray(): ByteArray { - val length = this.length.toInt() - if (length <= 0) return ByteArray(0) - return this.bytes?.readBytes(length) ?: ByteArray(0) -} diff --git a/shared/src/iosMain/kotlin/com/vnidrop/app/feature/send/PlatformPreviewStore.ios.kt b/shared/src/iosMain/kotlin/com/vnidrop/app/feature/send/PlatformPreviewStore.ios.kt deleted file mode 100644 index deffba1..0000000 --- a/shared/src/iosMain/kotlin/com/vnidrop/app/feature/send/PlatformPreviewStore.ios.kt +++ /dev/null @@ -1,62 +0,0 @@ -package com.vnidrop.app.feature.send - -import kotlinx.cinterop.ExperimentalForeignApi -import kotlinx.cinterop.BetaInteropApi -import kotlinx.cinterop.addressOf -import kotlinx.cinterop.readBytes -import kotlinx.cinterop.usePinned -import platform.Foundation.NSData -import platform.Foundation.NSDate -import platform.Foundation.NSFileManager -import platform.Foundation.NSFileModificationDate -import platform.Foundation.NSFileSize -import platform.Foundation.NSNumber -import platform.Foundation.create -import platform.Foundation.dataWithContentsOfFile -import platform.Foundation.timeIntervalSince1970 -import platform.Foundation.writeToFile - -actual fun createPlatformPreviewStore(appDataDir: String): PlatformPreviewStore = - IosPreviewStore(appDataDir.trimEnd('/') + "/ui/previews") - -@OptIn(ExperimentalForeignApi::class, BetaInteropApi::class) -private class IosPreviewStore(private val directory: String) : PlatformPreviewStore { - private val files = NSFileManager.defaultManager - - override fun list(): List { - ensureDirectory() - return files.contentsOfDirectoryAtPath(directory, null).orEmpty().filterIsInstance().mapNotNull { name -> - val id = name.removeSuffix(".preview").toULongOrNull() ?: return@mapNotNull null - val attributes = files.attributesOfItemAtPath("$directory/$name", null) ?: return@mapNotNull null - val size = (attributes[NSFileSize] as? NSNumber)?.longLongValue ?: 0L - val modified = ((attributes[NSFileModificationDate] as? NSDate)?.timeIntervalSince1970 ?: 0.0) * 1000.0 - PreviewFileInfo(id, size, modified.toLong()) - } - } - - override fun read(transferId: ULong): ByteArray? { - val data = NSData.dataWithContentsOfFile(path(transferId)) ?: return null - return data.bytes?.readBytes(data.length.toInt()) - } - - override fun writeAtomically(transferId: ULong, bytes: ByteArray): Boolean { - ensureDirectory() - if (files.fileExistsAtPath(path(transferId))) return true - val temporary = "$directory/.$transferId.tmp" - val data = bytes.usePinned { pinned -> NSData.create(bytes = pinned.addressOf(0), length = bytes.size.toULong()) } - if (!data.writeToFile(temporary, atomically = true)) return false - val moved = files.moveItemAtPath(temporary, path(transferId), null) - if (!moved) files.removeItemAtPath(temporary, null) - return moved - } - - override fun delete(transferId: ULong) { - files.removeItemAtPath(path(transferId), null) - } - - private fun ensureDirectory() { - files.createDirectoryAtPath(directory, withIntermediateDirectories = true, attributes = null, error = null) - } - - private fun path(transferId: ULong) = "$directory/$transferId.preview" -} diff --git a/shared/src/iosMain/kotlin/com/vnidrop/app/feature/send/TransferShareActions.ios.kt b/shared/src/iosMain/kotlin/com/vnidrop/app/feature/send/TransferShareActions.ios.kt deleted file mode 100644 index 130e3d3..0000000 --- a/shared/src/iosMain/kotlin/com/vnidrop/app/feature/send/TransferShareActions.ios.kt +++ /dev/null @@ -1,203 +0,0 @@ -package com.vnidrop.app.feature.send - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import com.vnidrop.app.feature.receive.VniDropInvitationMimeType -import kotlinx.cinterop.BetaInteropApi -import kotlinx.cinterop.ExperimentalForeignApi -import kotlinx.cinterop.ObjCSignatureOverride -import platform.CoreNFC.NFCNDEFMessage -import platform.CoreNFC.NFCNDEFPayload -import platform.CoreNFC.NFCNDEFReaderSession -import platform.CoreNFC.NFCNDEFReaderSessionDelegateProtocol -import platform.CoreNFC.NFCNDEFStatusNotSupported -import platform.CoreNFC.NFCNDEFStatusReadOnly -import platform.CoreNFC.NFCNDEFTagProtocol -import platform.CoreNFC.NFCTypeNameFormatMedia -import platform.Foundation.NSData -import platform.Foundation.NSError -import platform.Foundation.NSString -import platform.Foundation.NSTemporaryDirectory -import platform.Foundation.NSUTF8StringEncoding -import platform.Foundation.NSURL -import platform.Foundation.create -import platform.Foundation.dataUsingEncoding -import platform.Foundation.writeToFile -import platform.UIKit.UIActivityViewController -import platform.UIKit.UIApplication -import platform.UIKit.UIDocumentPickerViewController -import platform.UIKit.UIModalPresentationFormSheet -import platform.darwin.NSObject -import platform.darwin.dispatch_get_main_queue - -private var retainedNfcWriter: InvitationNfcWriter? = null - -@OptIn(ExperimentalForeignApi::class) -@Composable -actual fun rememberTransferShareActions(): TransferShareActions = remember { - object : TransferShareActions { - override val canUseNativeShare = true - override val nfcAvailability = - if (NFCNDEFReaderSession.readingAvailable) { - NfcShareAvailability.Available - } else { - NfcShareAvailability.Unavailable - } - - override fun exportInvitation(ticket: String, transferName: String, onResult: (Result) -> Unit) { - onResult(runCatching { - val url = createInvitation(ticket, transferName) - val picker = UIDocumentPickerViewController(forExportingURLs = listOf(url), asCopy = true) - present(picker) - }) - } - - override fun shareInvitation(ticket: String, transferName: String, onResult: (Result) -> Unit) { - onResult(runCatching { - val url = createInvitation(ticket, transferName) - val controller = UIActivityViewController(activityItems = listOf(url), applicationActivities = null) - controller.modalPresentationStyle = UIModalPresentationFormSheet - presenter().presentViewController(controller, animated = true, completion = null) - }) - } - - override fun writeInvitationToNfc(ticket: String, onResult: (Result) -> Unit) { - cancelNfcWrite() - if (!NFCNDEFReaderSession.readingAvailable) { - onResult(Result.failure(UnsupportedOperationException("NFC is unavailable on this device"))) - return - } - val writer = InvitationNfcWriter(ticket) { result -> - retainedNfcWriter = null - onResult(result) - } - retainedNfcWriter = writer - writer.start() - } - - override fun cancelNfcWrite() { - retainedNfcWriter?.cancel() - retainedNfcWriter = null - } - } -} - -@OptIn(ExperimentalForeignApi::class, BetaInteropApi::class) -private fun createInvitation(ticket: String, transferName: String): NSURL { - val path = NSTemporaryDirectory().trimEnd('/') + "/" + invitationFileName(transferName) - val text = NSString.create(string = ticket) - require(text.writeToFile(path, atomically = true, encoding = NSUTF8StringEncoding, error = null)) { - "The invitation file could not be created" - } - return NSURL.fileURLWithPath(path) -} - -private fun presenter() = UIApplication.sharedApplication.keyWindow?.rootViewController - ?: error("Could not find an iOS view controller") - -private fun present(controller: platform.UIKit.UIViewController) { - presenter().presentViewController(controller, animated = true, completion = null) -} - -@OptIn(ExperimentalForeignApi::class, BetaInteropApi::class) -private class InvitationNfcWriter( - private val ticket: String, - private val onResult: (Result) -> Unit, -) : NSObject(), NFCNDEFReaderSessionDelegateProtocol { - private var session: NFCNDEFReaderSession? = null - private var finished = false - - fun start() { - val reader = NFCNDEFReaderSession(this, dispatch_get_main_queue(), invalidateAfterFirstRead = false) - reader.alertMessage = "Hold your iPhone near a writable NFC tag" - session = reader - reader.beginSession() - } - - fun cancel() { - session?.invalidateSession() - session = null - } - - override fun readerSession(session: NFCNDEFReaderSession, didInvalidateWithError: NSError) { - if (finished) return - // NFCReaderError.readerSessionInvalidationErrorUserCanceled == 200 - val cancelled = didInvalidateWithError.code == 200L - finish( - if (cancelled) { - Result.failure(IllegalStateException("NFC writing was cancelled")) - } else { - Result.failure( - IllegalStateException(didInvalidateWithError.localizedDescription), - ) - }, - ) - } - - @ObjCSignatureOverride - override fun readerSession(session: NFCNDEFReaderSession, didDetectNDEFs: List<*>) { - // Prefer tag-based write path via didDetectTags when available. - } - - @ObjCSignatureOverride - override fun readerSession(session: NFCNDEFReaderSession, didDetectTags: List<*>) { - val tag = didDetectTags.firstOrNull() as? NFCNDEFTagProtocol - ?: return finish(Result.failure(IllegalStateException("No NFC tag was detected"))) - - session.connectToTag(tag) { connectError -> - if (connectError != null) { - finish(Result.failure(IllegalStateException(connectError.localizedDescription))) - return@connectToTag - } - tag.queryNDEFStatusWithCompletionHandler { status, _, queryError -> - if (queryError != null) { - finish(Result.failure(IllegalStateException(queryError.localizedDescription))) - return@queryNDEFStatusWithCompletionHandler - } - when (status) { - NFCNDEFStatusNotSupported -> { - finish(Result.failure(IllegalStateException("This NFC tag does not support NDEF"))) - } - NFCNDEFStatusReadOnly -> { - finish(Result.failure(IllegalStateException("This NFC tag is read-only"))) - } - else -> { - val message = invitationNdefMessage(ticket) - ?: return@queryNDEFStatusWithCompletionHandler finish( - Result.failure(IllegalStateException("Could not encode the invitation for NFC")), - ) - tag.writeNDEF(message) { writeError -> - if (writeError != null) { - finish(Result.failure(IllegalStateException(writeError.localizedDescription))) - } else { - session.alertMessage = "Invitation written" - session.invalidateSession() - finish(Result.success(Unit)) - } - } - } - } - } - } - } - - private fun finish(result: Result) { - if (finished) return - finished = true - session = null - onResult(result) - } -} - -@OptIn(ExperimentalForeignApi::class, BetaInteropApi::class) -private fun invitationNdefMessage(ticket: String): NFCNDEFMessage? { - val type = NSString.create(string = VniDropInvitationMimeType).dataUsingEncoding(NSUTF8StringEncoding) ?: return null - val payload = NSString.create(string = ticket).dataUsingEncoding(NSUTF8StringEncoding) ?: return null - val record = NFCNDEFPayload( - format = NFCTypeNameFormatMedia, - type = type, - identifier = NSData(), - payload = payload, - ) - return NFCNDEFMessage(nDEFRecords = listOf(record)) -} diff --git a/shared/src/iosMain/kotlin/com/vnidrop/app/logging/PlatformLogStore.ios.kt b/shared/src/iosMain/kotlin/com/vnidrop/app/logging/PlatformLogStore.ios.kt deleted file mode 100644 index d7a8aaf..0000000 --- a/shared/src/iosMain/kotlin/com/vnidrop/app/logging/PlatformLogStore.ios.kt +++ /dev/null @@ -1,149 +0,0 @@ -package com.vnidrop.app.logging - -import kotlinx.cinterop.ExperimentalForeignApi -import kotlinx.cinterop.addressOf -import kotlinx.cinterop.convert -import kotlinx.cinterop.usePinned -import platform.Foundation.NSData -import platform.Foundation.NSDate -import platform.Foundation.NSFileManager -import platform.Foundation.NSFileModificationDate -import platform.Foundation.NSFileSize -import platform.Foundation.NSNumber -import platform.Foundation.dataWithContentsOfFile -import platform.Foundation.timeIntervalSince1970 -import platform.posix.fclose -import platform.posix.fopen -import platform.posix.fwrite -import platform.posix.memcpy - -actual fun createPlatformLogStore(appDataDir: String, policy: LogRotationPolicy): PlatformLogStore = - IosPlatformLogStore(appDataDir, policy) - -actual fun platformNowMillis(): Long = - (NSDate().timeIntervalSince1970 * 1000.0).toLong() - -@OptIn(ExperimentalForeignApi::class) -private class IosPlatformLogStore( - appDataDir: String, - private val policy: LogRotationPolicy, -) : PlatformLogStore { - private val fileManager = NSFileManager.defaultManager - private val directory = appDataDir.trimEnd('/') + "/logs" - private val activePath = "$directory/app.log" - - override val logDirectory: String = directory - - override fun append(line: String) { - ensureDirectory() - val bytes = line.encodeToByteArray() - if (policy.shouldRotate(fileSize(activePath), bytes.size.toLong())) { - rotate() - } - val file = fopen(activePath, "ab") ?: return - try { - bytes.usePinned { pinned -> - fwrite(pinned.addressOf(0), 1.convert(), bytes.size.convert(), file) - } - } finally { - fclose(file) - } - } - - override fun listLogFiles(): List { - ensureDirectory() - val names = fileManager.contentsOfDirectoryAtPath(directory, null).orEmpty() - .filterIsInstance() - .filter { it.startsWith("app") && it.endsWith(".log") } - return names - .map { name -> - val path = "$directory/$name" - LogFileInfo(name, path, fileSize(path), modifiedAt(path)) - } - .sortedByDescending { it.modifiedAtMillis } - } - - override fun readLatest(maxBytes: Long): String { - if (maxBytes <= 0) return "" - ensureDirectory() - val paths = listOf(activePath) + - (1..policy.maxFiles).map { "$directory/app.$it.log" } - val chunks = ArrayList() - var remaining = maxBytes - for (path in paths) { - if (remaining <= 0 || !fileManager.fileExistsAtPath(path)) continue - val slice = readTail(path, remaining) - if (slice.isEmpty()) continue - chunks.add(0, slice) - remaining -= slice.size.toLong() - } - if (chunks.isEmpty()) return "" - val total = chunks.sumOf { it.size } - val out = ByteArray(total) - var offset = 0 - for (chunk in chunks) { - chunk.copyInto(out, offset) - offset += chunk.size - } - return out.decodeToString() - } - - private fun rotate() { - if (policy.maxFiles == 0) { - fileManager.removeItemAtPath(activePath, null) - return - } - fileManager.removeItemAtPath("$directory/app.${policy.maxFiles}.log", null) - for (index in policy.maxFiles - 1 downTo 1) { - val source = "$directory/app.$index.log" - if (fileManager.fileExistsAtPath(source)) { - fileManager.moveItemAtPath(source, "$directory/app.${index + 1}.log", null) - } - } - if (fileManager.fileExistsAtPath(activePath)) { - fileManager.moveItemAtPath(activePath, "$directory/app.1.log", null) - } - } - - private fun ensureDirectory() { - fileManager.createDirectoryAtPath(directory, withIntermediateDirectories = true, attributes = null, error = null) - } - - private fun fileSize(path: String): Long { - val attributes = fileManager.attributesOfItemAtPath(path, null) ?: return 0L - return (attributes[NSFileSize] as? NSNumber)?.longLongValue ?: 0L - } - - private fun modifiedAt(path: String): Long { - val attributes = fileManager.attributesOfItemAtPath(path, null) ?: return 0L - val date = attributes[NSFileModificationDate] as? NSDate ?: return 0L - return (date.timeIntervalSince1970 * 1000.0).toLong() - } - - private fun readTail(path: String, maxBytes: Long): ByteArray { - val data = NSData.dataWithContentsOfFile(path) ?: return ByteArray(0) - val all = data.toByteArray() - if (all.isEmpty() || maxBytes <= 0) return ByteArray(0) - if (all.size.toLong() <= maxBytes) return all - val start = all.size - maxBytes.toInt() - val slice = all.copyOfRange(start, all.size) - val newline = slice.indexOf('\n'.code.toByte()) - return if (newline in 0 until slice.lastIndex) { - slice.copyOfRange(newline + 1, slice.size) - } else { - slice - } - } -} - -@OptIn(ExperimentalForeignApi::class) -private fun NSData.toByteArray(): ByteArray { - val size = length.toInt() - if (size == 0) return ByteArray(0) - val result = ByteArray(size) - val source = bytes ?: return ByteArray(0) - result.usePinned { pinned -> - memcpy(pinned.addressOf(0), source, size.convert()) - } - return result -} diff --git a/shared/src/iosMain/kotlin/com/vnidrop/app/notifications/LocalNotificationService.ios.kt b/shared/src/iosMain/kotlin/com/vnidrop/app/notifications/LocalNotificationService.ios.kt deleted file mode 100644 index 9d52847..0000000 --- a/shared/src/iosMain/kotlin/com/vnidrop/app/notifications/LocalNotificationService.ios.kt +++ /dev/null @@ -1,99 +0,0 @@ -package com.vnidrop.app.notifications - -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.suspendCancellableCoroutine -import platform.Foundation.NSURL -import platform.UIKit.UIApplication -import platform.UIKit.UIApplicationOpenNotificationSettingsURLString -import platform.UserNotifications.UNAuthorizationOptionAlert -import platform.UserNotifications.UNAuthorizationOptionSound -import platform.UserNotifications.UNAuthorizationStatusAuthorized -import platform.UserNotifications.UNAuthorizationStatusDenied -import platform.UserNotifications.UNAuthorizationStatusEphemeral -import platform.UserNotifications.UNAuthorizationStatusNotDetermined -import platform.UserNotifications.UNAuthorizationStatusProvisional -import platform.UserNotifications.UNMutableNotificationContent -import platform.UserNotifications.UNNotificationRequest -import platform.UserNotifications.UNUserNotificationCenter -import kotlin.coroutines.resume - -class IosLocalNotificationService : LocalNotificationService { - private val center = UNUserNotificationCenter.currentNotificationCenter() - private val _permission = MutableStateFlow(NotificationPermission.NotDetermined) - override val permission: StateFlow = _permission.asStateFlow() - - override suspend fun refreshPermission(): NotificationPermission = suspendCancellableCoroutine { continuation -> - center.getNotificationSettingsWithCompletionHandler { settings -> - val mapped = when (settings?.authorizationStatus) { - UNAuthorizationStatusAuthorized, - UNAuthorizationStatusProvisional, - UNAuthorizationStatusEphemeral -> NotificationPermission.Granted - UNAuthorizationStatusDenied -> NotificationPermission.Denied - UNAuthorizationStatusNotDetermined -> NotificationPermission.NotDetermined - else -> NotificationPermission.Unsupported - } - _permission.value = mapped - if (continuation.isActive) continuation.resume(mapped) - } - } - - override suspend fun requestPermission(): NotificationPermission { - val current = refreshPermission() - if (current != NotificationPermission.NotDetermined) return current - return suspendCancellableCoroutine { continuation -> - center.requestAuthorizationWithOptions( - options = UNAuthorizationOptionAlert or UNAuthorizationOptionSound, - completionHandler = { granted, _ -> - val result = if (granted) NotificationPermission.Granted else NotificationPermission.Denied - _permission.value = result - if (continuation.isActive) continuation.resume(result) - }, - ) - } - } - - override suspend fun openSettings(): Result { - val url = NSURL.URLWithString(UIApplicationOpenNotificationSettingsURLString) - ?: return Result.failure(IllegalStateException("Notification settings URL is unavailable")) - val opened = suspendCancellableCoroutine { continuation -> - UIApplication.sharedApplication.openURL(url, emptyMap()) { success -> - if (continuation.isActive) continuation.resume(success) - } - } - return if (opened) Result.success(Unit) else Result.failure(IllegalStateException("Could not open notification settings")) - } - - override suspend fun publish(notification: LocalNotification): Result = runCatching { - check(refreshPermission() == NotificationPermission.Granted) { "Notification permission is not granted" } - val content = UNMutableNotificationContent().apply { - setTitle(notification.title) - setBody(notification.body) - setSound(platform.UserNotifications.UNNotificationSound.defaultSound) - } - val request = UNNotificationRequest.requestWithIdentifier(notification.id, content, null) - suspendCancellableCoroutine { continuation -> - center.addNotificationRequest(request) { error -> - if (!continuation.isActive) return@addNotificationRequest - if (error == null) { - continuation.resume(Unit) - } else { - continuation.resumeWith( - Result.failure(IllegalStateException(error.localizedDescription)), - ) - } - } - } - } - - override suspend fun cancel(id: String) { - center.removePendingNotificationRequestsWithIdentifiers(listOf(id)) - center.removeDeliveredNotificationsWithIdentifiers(listOf(id)) - } - - override suspend fun cancelAll() { - center.removeAllPendingNotificationRequests() - center.removeAllDeliveredNotifications() - } -} diff --git a/shared/src/iosMain/kotlin/com/vnidrop/app/platform/PlatformSystemAppearance.ios.kt b/shared/src/iosMain/kotlin/com/vnidrop/app/platform/PlatformSystemAppearance.ios.kt deleted file mode 100644 index aaf3445..0000000 --- a/shared/src/iosMain/kotlin/com/vnidrop/app/platform/PlatformSystemAppearance.ios.kt +++ /dev/null @@ -1,19 +0,0 @@ -package com.vnidrop.app.platform - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.SideEffect -import platform.Foundation.NSNotificationCenter - -@Composable -actual fun PlatformSystemAppearance(isDarkTheme: Boolean) { - SideEffect { - // The Swift host owns the actual UIKit status bar style. Compose publishes - // the resolved theme here so the wrapper can update without coupling common - // UI code to iOS-specific view controller APIs. - NSNotificationCenter.defaultCenter.postNotificationName( - aName = "VniDropThemeChanged", - `object` = null, - userInfo = mapOf("isDark" to if (isDarkTheme) "true" else "false"), - ) - } -} diff --git a/shared/src/iosTest/kotlin/com/vnidrop/app/SharedLogicIOSTest.kt b/shared/src/iosTest/kotlin/com/vnidrop/app/SharedLogicIOSTest.kt deleted file mode 100644 index 50b507b..0000000 --- a/shared/src/iosTest/kotlin/com/vnidrop/app/SharedLogicIOSTest.kt +++ /dev/null @@ -1,27 +0,0 @@ -package com.vnidrop.app - -import platform.Foundation.NSTemporaryDirectory -import kotlin.test.Test -import kotlin.test.assertTrue -import uniffi.vnidrop.CoreEvent -import uniffi.vnidrop.CoreEventSink -import uniffi.vnidrop.VnidropCore - -class SharedLogicIOSTest { - - @Test - fun generatedBindingsCanInitializeRustCore() { - val core = VnidropCore.initialize( - appDataDir = NSTemporaryDirectory() + "vnidrop-ios-test", - eventSink = object : CoreEventSink { - override fun onEvent(event: CoreEvent) = Unit - }, - ) - - try { - assertTrue(core.status().endpointId.isNotBlank()) - } finally { - core.shutdown() - } - } -} diff --git a/shared/src/iosTest/kotlin/com/vnidrop/app/core/FileSystemServiceIosTest.kt b/shared/src/iosTest/kotlin/com/vnidrop/app/core/FileSystemServiceIosTest.kt deleted file mode 100644 index ecfc490..0000000 --- a/shared/src/iosTest/kotlin/com/vnidrop/app/core/FileSystemServiceIosTest.kt +++ /dev/null @@ -1,25 +0,0 @@ -package com.vnidrop.app.core - -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertTrue -import uniffi.vnidrop.SourceKind - -class FileSystemServiceIosTest { - @Test - fun sandboxPickerCopyMapsToPathSource() { - val picked = PickedShareFile( - value = "/tmp/VniDrop/photos", - displayName = "photos", - isTemporaryCopy = true, - isDirectory = true, - ) - - val source = picked.toIosShareSource() - - assertEquals(SourceKind.PATH, source.kind) - assertEquals(picked.value, source.value) - assertEquals(picked.displayName, source.displayName) - assertTrue(source.isDirectory) - } -} diff --git a/shared/src/jvmMain/kotlin/com/vnidrop/app/core/FilePicker.jvm.kt b/shared/src/jvmMain/kotlin/com/vnidrop/app/core/FilePicker.jvm.kt index 052d82f..0643be2 100644 --- a/shared/src/jvmMain/kotlin/com/vnidrop/app/core/FilePicker.jvm.kt +++ b/shared/src/jvmMain/kotlin/com/vnidrop/app/core/FilePicker.jvm.kt @@ -128,44 +128,11 @@ private fun File.systemIconPng(): ByteArray? = runCatching { } }.getOrNull() -private fun pickDirectory(title: String): File? = - if (isMacOs()) { - val dialog = withMacDirectoryDialog { - nativeFileDialog(title).apply { isVisible = true } - } - try { - val directory = dialog.directory ?: return null - dialog.file - ?.let { File(directory, it) } - ?: File(directory) - } finally { - dialog.dispose() - } - } else { - val chooser = JFileChooser().apply { - dialogTitle = title - fileSelectionMode = JFileChooser.DIRECTORIES_ONLY - isAcceptAllFileFilterUsed = false - } - if (chooser.showOpenDialog(null) == JFileChooser.APPROVE_OPTION) chooser.selectedFile else null - } - -private fun withMacDirectoryDialog(block: () -> T): T { - if (!isMacOs()) return block() - - val key = "apple.awt.fileDialogForDirectories" - val previous = System.getProperty(key) - System.setProperty(key, "true") - return try { - block() - } finally { - if (previous == null) { - System.clearProperty(key) - } else { - System.setProperty(key, previous) - } +private fun pickDirectory(title: String): File? { + val chooser = JFileChooser().apply { + dialogTitle = title + fileSelectionMode = JFileChooser.DIRECTORIES_ONLY + isAcceptAllFileFilterUsed = false } + return if (chooser.showOpenDialog(null) == JFileChooser.APPROVE_OPTION) chooser.selectedFile else null } - -private fun isMacOs(): Boolean = - System.getProperty("os.name").startsWith("Mac", ignoreCase = true) diff --git a/shared/src/jvmMain/kotlin/com/vnidrop/app/feature/send/TransferShareActions.jvm.kt b/shared/src/jvmMain/kotlin/com/vnidrop/app/feature/send/TransferShareActions.jvm.kt index 11029f4..9c8b299 100644 --- a/shared/src/jvmMain/kotlin/com/vnidrop/app/feature/send/TransferShareActions.jvm.kt +++ b/shared/src/jvmMain/kotlin/com/vnidrop/app/feature/send/TransferShareActions.jvm.kt @@ -11,7 +11,7 @@ import java.io.File @Composable actual fun rememberTransferShareActions(): TransferShareActions = remember { object : TransferShareActions { - override val canUseNativeShare = DesktopShareBridge.shareFile != null + override val canUseNativeShare = false override val nfcAvailability = NfcShareAvailability.Hidden override fun exportInvitation(ticket: String, transferName: String, onResult: (Result) -> Unit) { @@ -31,18 +31,7 @@ actual fun rememberTransferShareActions(): TransferShareActions = remember { } override fun shareInvitation(ticket: String, transferName: String, onResult: (Result) -> Unit) { - EventQueue.invokeLater { - val share = DesktopShareBridge.shareFile - if (share == null) { - onResult(Result.failure(UnsupportedOperationException("System sharing is unavailable on this desktop"))) - return@invokeLater - } - onResult(runCatching { - val directory = File(System.getProperty("java.io.tmpdir"), "vnidrop-share").apply { mkdirs() } - val file = File(directory, invitationFileName(transferName)).apply { writeText(ticket) } - share(file).getOrThrow() - }) - } + onResult(Result.failure(UnsupportedOperationException("System sharing is unavailable on this desktop"))) } override fun writeInvitationToNfc(ticket: String, onResult: (Result) -> Unit) { @@ -55,8 +44,3 @@ actual fun rememberTransferShareActions(): TransferShareActions = remember { private fun activeFrame(): Frame? = (KeyboardFocusManager.getCurrentKeyboardFocusManager().activeWindow as? Frame) ?: Frame.getFrames().firstOrNull { it.isActive || it.isFocused } - -object DesktopShareBridge { - @Volatile - var shareFile: ((File) -> Result)? = null -} diff --git a/shared/src/jvmMain/kotlin/com/vnidrop/app/platform/PlatformSystemAppearance.jvm.kt b/shared/src/jvmMain/kotlin/com/vnidrop/app/platform/PlatformSystemAppearance.jvm.kt index d40dfec..30abff5 100644 --- a/shared/src/jvmMain/kotlin/com/vnidrop/app/platform/PlatformSystemAppearance.jvm.kt +++ b/shared/src/jvmMain/kotlin/com/vnidrop/app/platform/PlatformSystemAppearance.jvm.kt @@ -1,83 +1,14 @@ package com.vnidrop.app.platform import androidx.compose.runtime.Composable -import androidx.compose.runtime.SideEffect -import java.awt.Color -import java.awt.EventQueue import java.awt.Frame -import java.awt.Window -import javax.swing.JFrame -import javax.swing.JRootPane @Composable -actual fun PlatformSystemAppearance(isDarkTheme: Boolean) { - SideEffect { - DesktopSystemAppearance.apply(isDarkTheme) - } -} - -internal object DesktopSystemAppearance { - private const val MAC_APPEARANCE_PROPERTY = "apple.awt.application.appearance" - private const val FULL_WINDOW_CONTENT_PROPERTY = "apple.awt.fullWindowContent" - private const val TRANSPARENT_TITLE_BAR_PROPERTY = "apple.awt.transparentTitleBar" - private const val WINDOW_TITLE_VISIBLE_PROPERTY = "apple.awt.windowTitleVisible" - - fun apply(isDarkTheme: Boolean) { - if (!DesktopAppearanceBridge.isMacOs()) return - System.setProperty(MAC_APPEARANCE_PROPERTY, macOsAppearanceName(isDarkTheme)) - EventQueue.invokeLater { - DesktopAppearanceBridge.applyNativeAppearance?.invoke(isDarkTheme) - Window.getWindows().forEach { window -> - applyWindowChrome(window, isDarkTheme) - } - } - } - - internal fun macOsAppearanceName(isDarkTheme: Boolean): String = - if (isDarkTheme) "NSAppearanceNameDarkAqua" else "NSAppearanceNameAqua" - - internal fun usesTransparentTitlebar(): Boolean = true - internal fun usesFullWindowContent(): Boolean = true - internal fun showsNativeWindowTitle(): Boolean = false - - internal fun titlebarBackground(isDarkTheme: Boolean): Color = - if (isDarkTheme) Color(0x21, 0x21, 0x21) else Color(0xF3, 0xF3, 0xF3) - - private fun applyWindowChrome(window: Window, isDarkTheme: Boolean) { - val background = titlebarBackground(isDarkTheme) - window.background = background - (window as? JFrame)?.rootPane?.let { rootPane -> applyRootPaneChrome(rootPane, background) } - } - - internal fun applyRootPaneChrome(rootPane: JRootPane, background: Color) { - // Extending the Compose surface beneath the native titlebar lets the - // window chrome and sidebar share one uninterrupted background. - rootPane.putClientProperty(FULL_WINDOW_CONTENT_PROPERTY, usesFullWindowContent()) - rootPane.putClientProperty(TRANSPARENT_TITLE_BAR_PROPERTY, usesTransparentTitlebar()) - rootPane.putClientProperty(WINDOW_TITLE_VISIBLE_PROPERTY, showsNativeWindowTitle()) - rootPane.background = background - rootPane.contentPane.background = background - } -} +actual fun PlatformSystemAppearance(isDarkTheme: Boolean) = Unit object DesktopAppearanceBridge { - @Volatile - var applyNativeAppearance: ((Boolean) -> Unit)? = null - - fun isMacOs(): Boolean = isMacOs(System.getProperty("os.name")) fun isLinux(): Boolean = isLinux(System.getProperty("os.name")) - fun toggleMaximized(window: Window) { - if (!isMacOs()) return - val frame = window as? Frame ?: return - EventQueue.invokeLater { - frame.extendedState = toggledWindowState(frame.extendedState) - } - } - - internal fun isMacOs(osName: String): Boolean = - osName.startsWith("Mac", ignoreCase = true) - internal fun isLinux(osName: String): Boolean = osName.startsWith("Linux", ignoreCase = true) diff --git a/shared/src/jvmTest/kotlin/com/vnidrop/app/platform/DesktopAppearanceBridgeTest.kt b/shared/src/jvmTest/kotlin/com/vnidrop/app/platform/DesktopAppearanceBridgeTest.kt new file mode 100644 index 0000000..8b2932c --- /dev/null +++ b/shared/src/jvmTest/kotlin/com/vnidrop/app/platform/DesktopAppearanceBridgeTest.kt @@ -0,0 +1,22 @@ +package com.vnidrop.app.platform + +import java.awt.Frame +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class DesktopAppearanceBridgeTest { + @Test + fun customWindowChromeIsLimitedToLinux() { + assertFalse(DesktopAppearanceBridge.isLinux("Mac OS X")) + assertTrue(DesktopAppearanceBridge.isLinux("Linux")) + assertFalse(DesktopAppearanceBridge.isLinux("Windows 11")) + } + + @Test + fun titlebarDoubleClickTogglesMaximizedWindowState() { + assertEquals(Frame.MAXIMIZED_BOTH, DesktopAppearanceBridge.toggledWindowState(Frame.NORMAL)) + assertEquals(Frame.NORMAL, DesktopAppearanceBridge.toggledWindowState(Frame.MAXIMIZED_BOTH)) + } +} diff --git a/shared/src/jvmTest/kotlin/com/vnidrop/app/platform/DesktopSystemAppearanceTest.kt b/shared/src/jvmTest/kotlin/com/vnidrop/app/platform/DesktopSystemAppearanceTest.kt deleted file mode 100644 index 4c7bd2f..0000000 --- a/shared/src/jvmTest/kotlin/com/vnidrop/app/platform/DesktopSystemAppearanceTest.kt +++ /dev/null @@ -1,64 +0,0 @@ -package com.vnidrop.app.platform - -import java.awt.Color -import java.awt.Frame -import javax.swing.JRootPane -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertTrue - -class DesktopSystemAppearanceTest { - @Test - fun customWindowChromeSupportsMacOsAndLinux() { - assertTrue(DesktopAppearanceBridge.isMacOs("Mac OS X")) - assertFalse(DesktopAppearanceBridge.isLinux("Mac OS X")) - assertTrue(DesktopAppearanceBridge.isLinux("Linux")) - assertFalse(DesktopAppearanceBridge.isMacOs("Linux")) - assertFalse(DesktopAppearanceBridge.isMacOs("Windows 11")) - assertFalse(DesktopAppearanceBridge.isLinux("Windows 11")) - } - - @Test - fun titlebarDoubleClickTogglesMaximizedWindowState() { - assertEquals(Frame.MAXIMIZED_BOTH, DesktopAppearanceBridge.toggledWindowState(Frame.NORMAL)) - assertEquals(Frame.NORMAL, DesktopAppearanceBridge.toggledWindowState(Frame.MAXIMIZED_BOTH)) - } - - @Test - fun macOsAppearanceNamesMatchResolvedTheme() { - assertEquals("NSAppearanceNameDarkAqua", DesktopSystemAppearance.macOsAppearanceName(isDarkTheme = true)) - assertEquals("NSAppearanceNameAqua", DesktopSystemAppearance.macOsAppearanceName(isDarkTheme = false)) - } - - @Test - fun titlebarBackgroundMatchesSidebarSurface() { - assertEquals(0x212121, DesktopSystemAppearance.titlebarBackground(isDarkTheme = true).rgb and 0xFFFFFF) - assertEquals(0xF3F3F3, DesktopSystemAppearance.titlebarBackground(isDarkTheme = false).rgb and 0xFFFFFF) - } - - @Test - fun transparentTitlebarIsAlwaysUsedWithAppKitAppearance() { - assertEquals(true, DesktopSystemAppearance.usesTransparentTitlebar()) - } - - @Test - fun composeContentExtendsUnderMacOsTitlebar() { - val rootPane = JRootPane() - val background = Color(0x21, 0x21, 0x21) - - DesktopSystemAppearance.applyRootPaneChrome(rootPane, background) - - assertEquals(true, rootPane.getClientProperty("apple.awt.fullWindowContent")) - assertEquals(true, rootPane.getClientProperty("apple.awt.transparentTitleBar")) - assertEquals(false, rootPane.getClientProperty("apple.awt.windowTitleVisible")) - assertEquals(background, rootPane.background) - assertEquals(background, rootPane.contentPane.background) - } - - @Test - fun runtimeAppearanceCallIsFailSoft() { - DesktopSystemAppearance.apply(isDarkTheme = true) - DesktopSystemAppearance.apply(isDarkTheme = false) - } -}