refactor(platform): remove Apple targets from KMP

This commit is contained in:
2026-07-21 17:09:59 +02:00
parent d8eaf78997
commit 9e8564e013
75 changed files with 118 additions and 2930 deletions

View File

@@ -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(

View File

@@ -134,13 +134,6 @@ interface CoreGateway {
senderName: String,
accessPolicy: ShareAccessPolicy,
): Result<Share>
suspend fun shareSecurityScopedFileUrl(
fileUrl: String,
displayName: String,
transferName: String,
senderName: String,
accessPolicy: ShareAccessPolicy,
): Result<Share>
/** Multi-source share used by multi-file pickers. */
suspend fun shareSources(
sources: List<uniffi.vnidrop.ShareSource>,
@@ -151,7 +144,6 @@ interface CoreGateway {
suspend fun inspectTicket(ticket: String): Result<TicketInspectionModel>
suspend fun receive(ticket: String, outputDir: String, receiverName: String): Result<Unit>
suspend fun receiveWithOutputSink(ticket: String, outputSink: ReceiveOutputSink, receiverName: String): Result<Unit>
suspend fun receiveIntoSecurityScopedDirectory(ticket: String, outputDirectoryUrl: String, receiverName: String): Result<Unit>
suspend fun cancel(transferId: ULong): Result<Unit>
suspend fun delete(transferId: ULong): Result<Unit>
suspend fun clearReceiveHistory(): Result<ULong>

View File

@@ -113,27 +113,6 @@ class CoreRepository(
accessPolicy = accessPolicy,
)
override suspend fun shareSecurityScopedFileUrl(
fileUrl: String,
displayName: String,
transferName: String,
senderName: String,
accessPolicy: ShareAccessPolicy,
): Result<Share> =
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<TicketInspectionModel> = 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<Unit> = runCore {
withPlatformPathAccess(SourceKind.IOS_SECURITY_SCOPED_URL, outputDirectoryUrl) {
requireCore().receive(ticket, outputDirectoryUrl, receiverName.ifBlank { null })
}
refreshSnapshot()
}
override suspend fun cancel(transferId: ULong): Result<Unit> = runCore {
requireCore().cancelTransfer(transferId)
refreshSnapshot()

View File

@@ -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,
)

View File

@@ -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]. */

View File

@@ -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 <T> withPlatformPathAccess(
kind: SourceKind,
value: String,

View File

@@ -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 = {

View File

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

View File

@@ -89,13 +89,6 @@ class FakeCoreGateway : CoreGateway {
senderName: String,
accessPolicy: ShareAccessPolicy,
) = Result.failure<Share>(UnsupportedOperationException())
override suspend fun shareSecurityScopedFileUrl(
fileUrl: String,
displayName: String,
transferName: String,
senderName: String,
accessPolicy: ShareAccessPolicy,
) = Result.failure<Share>(UnsupportedOperationException())
override suspend fun shareSources(
sources: List<uniffi.vnidrop.ShareSource>,
transferName: String,
@@ -142,13 +135,6 @@ class FakeCoreGateway : CoreGateway {
awaitReceiveIfNeeded()
return receiveResult
}
override suspend fun receiveIntoSecurityScopedDirectory(ticket: String, outputDirectoryUrl: String, receiverName: String): Result<Unit> {
receiveCount += 1
lastReceiveTicket = ticket
lastReceiveReceiverName = receiverName
awaitReceiveIfNeeded()
return receiveResult
}
override suspend fun cancel(transferId: ULong): Result<Unit> {
cancelledTransfers += transferId
return Result.success(Unit)

View File

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

View File

@@ -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(),
)
}

View File

@@ -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<PickedShareFile>) -> 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<PickedShareFile>) -> 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()

View File

@@ -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<PickedShareFile>) {
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<Unit> {
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<Any?, Any>()) { 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<PickedShareFile>,
transferName: String,
senderName: String,
accessPolicy: ShareAccessPolicy,
): Result<Share> {
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,
)

View File

@@ -1,24 +0,0 @@
package com.vnidrop.app.core
import platform.Foundation.NSURL
import uniffi.vnidrop.SourceKind
internal actual suspend fun <T> 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()
}
}
}

View File

@@ -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<CrashReport> {
ensureDirectory()
val names = fileManager.contentsOfDirectoryAtPath(directory, null).orEmpty()
.filterIsInstance<String>()
.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<String>()
.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()
}

View File

@@ -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
}

View File

@@ -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<String, String>,
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()
}

View File

@@ -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<String>) -> 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<String>) -> 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<String>) -> 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<String>) -> 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<String>) -> 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<ObjCObjectVar<NSError?>>()
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<String>) {
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<String>) -> 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<String>) {
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)
}

View File

@@ -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<PreviewFileInfo> {
ensureDirectory()
return files.contentsOfDirectoryAtPath(directory, null).orEmpty().filterIsInstance<String>().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"
}

View File

@@ -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>) -> 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>) -> 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>) -> 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>) -> 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<Unit>) {
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))
}

View File

@@ -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<LogFileInfo> {
ensureDirectory()
val names = fileManager.contentsOfDirectoryAtPath(directory, null).orEmpty()
.filterIsInstance<String>()
.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<ByteArray>()
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
}

View File

@@ -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<NotificationPermission> = _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<Unit> {
val url = NSURL.URLWithString(UIApplicationOpenNotificationSettingsURLString)
?: return Result.failure(IllegalStateException("Notification settings URL is unavailable"))
val opened = suspendCancellableCoroutine { continuation ->
UIApplication.sharedApplication.openURL(url, emptyMap<Any?, Any>()) { 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<Unit> = 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()
}
}

View File

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

View File

@@ -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()
}
}
}

View File

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

View File

@@ -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 <T> 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)

View File

@@ -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>) -> Unit) {
@@ -31,18 +31,7 @@ actual fun rememberTransferShareActions(): TransferShareActions = remember {
}
override fun shareInvitation(ticket: String, transferName: String, onResult: (Result<Unit>) -> 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>) -> 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<Unit>)? = null
}

View File

@@ -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)

View File

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

View File

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