diff --git a/app/README.md b/app/README.md index 819ccdf..2c2d2a1 100644 --- a/app/README.md +++ b/app/README.md @@ -116,3 +116,30 @@ Shortcuts. Load the Library and Clippings online, turn on airplane mode, close t app and run each action; check the returned fields and unchanged Cached At. Sign out and confirm actions no longer return the previous account's data. Sign into a different account and verify a previously selected book cannot resolve to it. + +### Importing files on mobile + +Share or open books (`.epub`, `.acsm`, `.md`, `.txt`), fonts (`.ttf`, `.otf`, +`.cpfont`), and sleep-screen images (`.png`, `.jpg`, `.jpeg`, `.webp`, `.gif`, +`.bmp`) in CrossPoint Sync to queue them in **Send to your reader**. Imports are copied into app-owned storage and +survive tab changes, restarts, and an unavailable reader. Tap Send when ready; +a successful send deletes its queued copy. Failed sends stay queued, and Remove +deletes only the app's copy. Files with identical names remain separate entries. +Book file-picker imports use the same persistent queue. Shared font styles from +one family are sent together through the existing font APIs; duplicate copies +of one style remain separate. Raw bold/italic uploads do not replace regular. +Converting a family to `.cpfont` requires its regular style. Shared images open +in the sleep-screen editor for sizing/dithering and BMP conversion before send; +editing or saving a preview leaves the original queued. Mixed batches show +counts on each Send tab, and imports also work in guest mode. + +Android accepts Share (including multiple files) and Open intents. On iOS, +**Open in CrossPoint Sync** opens the app directly. The **CrossPoint Sync** Share +extension saves the files and confirms the result; open the app afterward to +send them. iOS does not provide a supported API for this Share extension to +launch its containing app. The extension uses the existing App Group +`group.com.crosspointreader.sync` and the new automatically signed bundle +identifier `com.crosspointreader.sync.share`. + +See [mobile import checks](../docs/mobile-file-imports-testing.md) for native +build and device verification. diff --git a/app/scripts/import-tests/main.swift b/app/scripts/import-tests/main.swift new file mode 100644 index 0000000..7fcd891 --- /dev/null +++ b/app/scripts/import-tests/main.swift @@ -0,0 +1,18 @@ +import Foundation + +// Exercise the actual extension activation predicate, rather than inspecting +// its text: a CPFONT-only provider must activate without public.file-url. +let path = CommandLine.arguments.count > 1 ? CommandLine.arguments[1] : "src-tauri/gen/apple/CrossPointShare/Info.plist" +let data = try Data(contentsOf: URL(fileURLWithPath: path)) +let info = try PropertyListSerialization.propertyList(from: data, format: nil) as! [String: Any] +let ext = info["NSExtension"] as! [String: Any] +let attrs = ext["NSExtensionAttributes"] as! [String: Any] +let rule = NSPredicate(format: attrs["NSExtensionActivationRule"] as! String) +for type in ["com.crosspointreader.cpfont", "public.png", "public.jpeg", "public.truetype-ttf-font", "public.opentype-font"] { + let input: [String: Any] = ["extensionItems": [["attachments": [["registeredTypeIdentifiers": [type]]]]]] + if !rule.evaluate(with: input) { + fputs("Share extension must accept \(type)\n", stderr) + exit(1) + } +} +print("Share activation checks passed") diff --git a/app/src-tauri/Cargo.lock b/app/src-tauri/Cargo.lock index 5350355..b8e4a96 100644 --- a/app/src-tauri/Cargo.lock +++ b/app/src-tauri/Cargo.lock @@ -690,6 +690,7 @@ version = "0.1.1" dependencies = [ "anyhow", "base64 0.22.1", + "block2", "objc2", "objc2-foundation", "objc2-ui-kit", diff --git a/app/src-tauri/Cargo.toml b/app/src-tauri/Cargo.toml index 04e5184..4ad1204 100644 --- a/app/src-tauri/Cargo.toml +++ b/app/src-tauri/Cargo.toml @@ -32,9 +32,11 @@ tauri-plugin-haptics = "2" # UIKit-managed iPhone/Duo navigation from our fork, pinned with the JS package. tauri-plugin-system-components = { git = "https://github.com/itsthisjustin/tauri-plugin-system-components", rev = "348476a5bd4ff19c95a944d9a5d93bf403d4e07b" } -[target.'cfg(target_os = "ios")'.dependencies] +[target.'cfg(any(target_os = "ios", target_os = "macos"))'.dependencies] +block2 = "0.6" objc2 = "0.6" -objc2-foundation = { version = "0.3", features = ["NSData", "NSFileManager", "NSString", "NSURL"] } +objc2-foundation = { version = "0.3", features = ["NSData", "NSFileManager", "NSString", "NSURL", "NSFileCoordinator", "NSError", "block2"] } +[target.'cfg(target_os = "ios")'.dependencies] objc2-ui-kit = { version = "0.3", default-features = false, features = ["std", "UIControl", "UIImage", "UIImagePickerController", "UIRefreshControl", "UIResponder", "UIView"] } [dev-dependencies] diff --git a/app/src-tauri/gen/android/app/src/main/AndroidManifest.xml b/app/src-tauri/gen/android/app/src/main/AndroidManifest.xml index 8a88b14..47f71d4 100644 --- a/app/src-tauri/gen/android/app/src/main/AndroidManifest.xml +++ b/app/src-tauri/gen/android/app/src/main/AndroidManifest.xml @@ -31,6 +31,55 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src-tauri/gen/android/app/src/main/java/com/crosspointreader/sync/IncomingFiles.kt b/app/src-tauri/gen/android/app/src/main/java/com/crosspointreader/sync/IncomingFiles.kt new file mode 100644 index 0000000..9d33460 --- /dev/null +++ b/app/src-tauri/gen/android/app/src/main/java/com/crosspointreader/sync/IncomingFiles.kt @@ -0,0 +1,65 @@ +package com.crosspointreader.sync + +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.provider.OpenableColumns +import java.io.File + +/** Same atomic directory format as Rust import_store and iOS ImportStore. */ +internal object IncomingFiles { + @Suppress("DEPRECATION") + fun uris(intent: Intent): List { + val streams: List = when (intent.action) { + Intent.ACTION_VIEW -> listOfNotNull(intent.data) + Intent.ACTION_SEND -> listOfNotNull(if (Build.VERSION.SDK_INT >= 33) intent.getParcelableExtra(Intent.EXTRA_STREAM, Uri::class.java) else intent.getParcelableExtra(Intent.EXTRA_STREAM)) + Intent.ACTION_SEND_MULTIPLE -> (if (Build.VERSION.SDK_INT >= 33) intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM, Uri::class.java) else intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM)) ?: emptyList() + else -> return emptyList() + } + val clips = intent.clipData?.let { clip -> (0 until clip.itemCount).mapNotNull { clip.getItemAt(it).uri } } ?: emptyList() + return (streams + clips).filter { it.scheme == "content" || it.scheme == "file" }.distinct() + } + + fun copy(context: Context, uri: Uri, id: String) { + require(id.matches(Regex("[a-zA-Z0-9-]+"))) + // Persist completion separately from the queue: rotating or restoring the + // activity must not resurrect a file the user already sent or removed. + val delivered = context.getSharedPreferences("import-deliveries", Context.MODE_PRIVATE) + if (delivered.getBoolean(id, false)) return + val root = File(context.applicationInfo.dataDir, "ImportQueue").apply { check(mkdirs() || isDirectory) } + val destination = File(root, id) + // A prior process may have died after publish but before recording delivery. + if (destination.isDirectory) { + check(delivered.edit().putBoolean(id, true).commit()) { "Couldn't record imported file" } + return + } + val resolver = context.contentResolver + val displayName = if (uri.scheme == "content") { + resolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use { cursor -> + val column = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) + if (column >= 0 && cursor.moveToFirst()) cursor.getString(column) else null + } + } else null + val name = displayName ?: uri.lastPathSegment ?: throw IllegalArgumentException("Missing file name") + require(validName(name)) { "Choose books, TTF/OTF/CPFONT fonts, or PNG/JPG/WebP/GIF/BMP sleep screens: $name" } + // Tauri PathPlugin.getDataDir resolves applicationInfo.dataDir, not filesDir. + val staging = File(root, ".$id") + staging.deleteRecursively() // Retry an incomplete copy after process death. + check(staging.mkdir()) { "Couldn't create import folder" } + try { + resolver.openInputStream(uri)?.use { input -> + File(staging, name).outputStream().use { output -> input.copyTo(output) } + } ?: throw IllegalArgumentException("Couldn't read $name") + check(staging.renameTo(destination)) { "Couldn't save $name" } + check(delivered.edit().putBoolean(id, true).commit()) { "Couldn't record imported file" } + } catch (error: Exception) { + staging.deleteRecursively() + throw error + } + } + + private fun validName(name: String): Boolean = + name.isNotEmpty() && !name.startsWith('.') && name.none { it == '/' || it == '\\' || it.isISOControl() } && + name.substringAfterLast('.', "").lowercase() in setOf("epub", "acsm", "md", "txt", "ttf", "otf", "cpfont", "png", "jpg", "jpeg", "webp", "gif", "bmp") +} diff --git a/app/src-tauri/gen/android/app/src/main/java/com/crosspointreader/sync/MainActivity.kt b/app/src-tauri/gen/android/app/src/main/java/com/crosspointreader/sync/MainActivity.kt index 5be8290..808051a 100644 --- a/app/src-tauri/gen/android/app/src/main/java/com/crosspointreader/sync/MainActivity.kt +++ b/app/src-tauri/gen/android/app/src/main/java/com/crosspointreader/sync/MainActivity.kt @@ -23,6 +23,8 @@ import java.io.File class MainActivity : TauriActivity() { companion object { + private val importExecutor = java.util.concurrent.Executors.newSingleThreadExecutor() + private const val DELIVERY_STATE = "crosspoint-import-delivery" private const val PAPER = 0xFFFAF8F3.toInt() private const val PAPER_DARK = 0xFF121110.toInt() } @@ -39,6 +41,8 @@ class MainActivity : TauriActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + importDelivery = savedInstanceState?.getString(DELIVERY_STATE) ?: java.util.UUID.randomUUID().toString() + receiveFiles(intent, importDelivery) val content = findViewById(android.R.id.content) val deadline = SystemClock.uptimeMillis() + 3000 content.viewTreeObserver.addOnPreDrawListener(object : ViewTreeObserver.OnPreDrawListener { @@ -58,6 +62,39 @@ class MainActivity : TauriActivity() { } } + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + setIntent(intent) + importDelivery = java.util.UUID.randomUUID().toString() + receiveFiles(intent, importDelivery) + } + + private var importDelivery = java.util.UUID.randomUUID().toString() + + override fun onSaveInstanceState(outState: Bundle) { + outState.putString(DELIVERY_STATE, importDelivery) + super.onSaveInstanceState(outState) + } + + private fun receiveFiles(incoming: Intent?, delivery: String) { + if (incoming == null) return + val uris = IncomingFiles.uris(incoming) + if (uris.isEmpty()) return + // Resolve and copy content:// streams while the activity holds the sender's + // temporary URI grant. No external-storage permission or network fetch. + importExecutor.execute { + val failures = mutableListOf() + for ((index, uri) in uris.withIndex()) { + try { IncomingFiles.copy(applicationContext, uri, "$delivery-$index") } + catch (error: Exception) { failures.add(error.message ?: "Couldn't import file") } + } + runOnUiThread { + webView?.evaluateJavascript("dispatchEvent(new Event('crosspoint-imports-changed'))", null) + if (failures.isNotEmpty()) android.widget.Toast.makeText(this, failures.joinToString("\n"), android.widget.Toast.LENGTH_LONG).show() + } + } + } + override fun onDestroy() { multicastLock?.release() multicastLock = null diff --git a/app/src-tauri/gen/apple/CrossPointShare/CrossPointShare.entitlements b/app/src-tauri/gen/apple/CrossPointShare/CrossPointShare.entitlements new file mode 100644 index 0000000..89e512b --- /dev/null +++ b/app/src-tauri/gen/apple/CrossPointShare/CrossPointShare.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.com.crosspointreader.sync + + + diff --git a/app/src-tauri/gen/apple/CrossPointShare/Info.plist b/app/src-tauri/gen/apple/CrossPointShare/Info.plist new file mode 100644 index 0000000..c83bde3 --- /dev/null +++ b/app/src-tauri/gen/apple/CrossPointShare/Info.plist @@ -0,0 +1,105 @@ + + + + + CFBundleDisplayName + CrossPoint Sync + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + XPC! + CFBundleShortVersionString + 0.1.2 + CFBundleVersion + 28 + NSExtension + + NSExtensionPointIdentifier + com.apple.share-services + NSExtensionPrincipalClass + $(PRODUCT_MODULE_NAME).ShareViewController + NSExtensionAttributes + + NSExtensionActivationRule + SUBQUERY(extensionItems, $item, SUBQUERY($item.attachments, $attachment, ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.file-url" OR ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "org.idpf.epub-container" OR ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "com.adobe.adept.acsm" OR ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.plain-text" OR ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "com.crosspointreader.cpfont" OR ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.font" OR ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.image").@count > 0).@count > 0 + + + UTImportedTypeDeclarations + + + UTTypeIdentifier + org.idpf.epub-container + UTTypeDescription + EPUB book + UTTypeConformsTo + + public.data + + UTTypeTagSpecification + + public.filename-extension + epub + public.mime-type + application/epub+zip + + + + UTTypeIdentifier + com.adobe.adept.acsm + UTTypeDescription + Adobe Content Server Message + UTTypeConformsTo + + public.xml + + UTTypeTagSpecification + + public.filename-extension + acsm + public.mime-type + application/vnd.adobe.adept+xml + + + + UTTypeIdentifier + net.daringfireball.markdown + UTTypeDescription + Markdown document + UTTypeConformsTo + + public.plain-text + + UTTypeTagSpecification + + public.filename-extension + md + public.mime-type + text/markdown + + + + UTTypeIdentifier + com.crosspointreader.cpfont + UTTypeDescription + CrossPoint prebuilt font + UTTypeConformsTo + + public.data + + UTTypeTagSpecification + + public.filename-extension + cpfont + public.mime-type + application/vnd.crosspoint.cpfont + + + + + diff --git a/app/src-tauri/gen/apple/CrossPointShare/ShareViewController.swift b/app/src-tauri/gen/apple/CrossPointShare/ShareViewController.swift new file mode 100644 index 0000000..b666b4c --- /dev/null +++ b/app/src-tauri/gen/apple/CrossPointShare/ShareViewController.swift @@ -0,0 +1,132 @@ +import UIKit +import UniformTypeIdentifiers + +/// Receives file representations inside their provider callback, before iOS +/// deletes the temporary URL. The containing app need not be running. +final class ShareViewController: UIViewController { + private let message = UILabel() + private let done = UIButton(type: .system) + private var started = false + private var saved = 0 + private var failures: [String] = [] + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .systemBackground + let title = UILabel() + title.text = "CrossPoint Sync" + title.font = .preferredFont(forTextStyle: .title2) + message.text = "Saving files…" + message.font = .preferredFont(forTextStyle: .body) + message.numberOfLines = 0 + done.setTitle("Done", for: .normal) + done.isEnabled = false + done.addTarget(self, action: #selector(close), for: .touchUpInside) + let stack = UIStackView(arrangedSubviews: [title, message, done]) + stack.axis = .vertical + stack.spacing = 20 + stack.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(stack) + NSLayoutConstraint.activate([ + stack.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 24), + stack.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -24), + stack.centerYAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerYAnchor) + ]) + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + guard !started else { return } + started = true + let items = extensionContext?.inputItems as? [NSExtensionItem] ?? [] + receive(items.flatMap { $0.attachments ?? [] }, at: 0) + } + + private func receive(_ providers: [NSItemProvider], at index: Int) { + guard index < providers.count else { + let summary = saved == 1 ? "1 file queued." : "\(saved) files queued." + message.text = (saved > 0 ? summary + " Open CrossPoint Sync to send them to your reader." : "No files queued.") + + (failures.isEmpty ? "" : "\n\n" + failures.joined(separator: "\n")) + done.isEnabled = true + return + } + let provider = providers[index] + let finish: (Error?) -> Void = { error in + DispatchQueue.main.async { + if let error = error { self.failures.append(error.localizedDescription) } + else { self.saved += 1 } + self.receive(providers, at: index + 1) + } + } + if provider.hasItemConformingToTypeIdentifier(UTType.fileURL.identifier) { + provider.loadItem(forTypeIdentifier: UTType.fileURL.identifier, options: nil) { item, error in + guard let url = item as? URL else { finish(error ?? ImportFailure.unreadable); return } + do { try ImportStore.copy(url, suggestedName: provider.suggestedName); finish(nil) } + catch { finish(error) } + } + return + } + let supported = ["org.idpf.epub-container", "com.adobe.adept.acsm", "net.daringfireball.markdown", UTType.plainText.identifier, "public.truetype-ttf-font", "public.opentype-font", "com.crosspointreader.cpfont", "public.image"] + guard let type = supported.first(where: { provider.hasItemConformingToTypeIdentifier($0) }) else { + finish(ImportFailure.unsupported); return + } + provider.loadFileRepresentation(forTypeIdentifier: type) { url, error in + guard let url = url else { finish(error ?? ImportFailure.unreadable); return } + do { try ImportStore.copy(url, suggestedName: provider.suggestedName); finish(nil) } + catch { finish(error) } + } + } + + @objc private func close() { extensionContext?.completeRequest(returningItems: nil) } +} + +enum ImportFailure: LocalizedError { + case unsupported, unreadable, storage + var errorDescription: String? { + switch self { + case .unsupported: return "Choose books, TTF/OTF/CPFONT fonts, or PNG/JPG/WebP/GIF/BMP sleep screens." + case .unreadable: return "Couldn't read the shared file." + case .storage: return "Couldn't access CrossPoint Sync storage." + } + } +} + +enum ImportStore { + static func validName(_ name: String) -> Bool { + !name.isEmpty && !name.hasPrefix(".") && !name.contains("/") && !name.contains("\\") && + name.rangeOfCharacter(from: .controlCharacters) == nil && + ["epub", "acsm", "md", "txt", "ttf", "otf", "cpfont", "png", "jpg", "jpeg", "webp", "gif", "bmp"].contains((name as NSString).pathExtension.lowercased()) + } + + static func copy(_ source: URL, suggestedName: String?) throws { + guard source.isFileURL else { throw ImportFailure.unreadable } + // A provider's suggested name preserves the original when its temporary + // representation has a generated name. Never accept path components. + let name = suggestedName.flatMap { validName($0) ? $0 : nil } ?? source.lastPathComponent + guard validName(name) else { throw ImportFailure.unsupported } + let manager = FileManager.default + guard let group = manager.containerURL(forSecurityApplicationGroupIdentifier: "group.com.crosspointreader.sync") else { + throw ImportFailure.storage + } + let root = group.appendingPathComponent("ImportQueue", isDirectory: true) + try manager.createDirectory(at: root, withIntermediateDirectories: true) + let id = UUID().uuidString + let staging = root.appendingPathComponent("." + id, isDirectory: true) + try manager.createDirectory(at: staging, withIntermediateDirectories: false) + let accessed = source.startAccessingSecurityScopedResource() + defer { + if accessed { source.stopAccessingSecurityScopedResource() } + try? manager.removeItem(at: staging) + } + // Coordinate with Files/iCloud providers while reading their document. + var coordinationError: NSError? + var copyError: Error? + NSFileCoordinator().coordinate(readingItemAt: source, options: [], error: &coordinationError) { readable in + do { try manager.copyItem(at: readable, to: staging.appendingPathComponent(name)) } + catch { copyError = error } + } + if let error = coordinationError { throw error } + if let error = copyError { throw error } + try manager.moveItem(at: staging, to: root.appendingPathComponent(id, isDirectory: true)) + } +} diff --git a/app/src-tauri/gen/apple/crosspoint-sync-app.xcodeproj/project.pbxproj b/app/src-tauri/gen/apple/crosspoint-sync-app.xcodeproj/project.pbxproj index e0d25cf..f95d575 100644 --- a/app/src-tauri/gen/apple/crosspoint-sync-app.xcodeproj/project.pbxproj +++ b/app/src-tauri/gen/apple/crosspoint-sync-app.xcodeproj/project.pbxproj @@ -31,6 +31,15 @@ E952F1320553223057A59239 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FE54F751F09F6D338C764211 /* WidgetKit.framework */; }; F507E6B34A0632CF9017FFF2 /* CrossPointWidget.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = BB2A4F2B0B619285749721A7 /* CrossPointWidget.appex */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; F5379C59BD1403E2BDA6098F /* assets in Resources */ = {isa = PBXBuildFile; fileRef = EFAC7081BE0965C9641C74F2 /* assets */; }; + 68EF11AFAF17A1B4A324D111 /* ShareViewController.swift in Sources */ = { + isa = PBXBuildFile; + fileRef = 7B070A0995F118DF9B3D65B0; + }; + 0E9D3268BD6043FEF43878EF /* CrossPointShare.appex in Embed Foundation Extensions */ = { + isa = PBXBuildFile; + fileRef = 2EF5C746673E376CB9E2CCBB; + settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; + }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -41,6 +50,13 @@ remoteGlobalIDString = F1B6382EBA17716854185CF2; remoteInfo = CrossPointWidget; }; + CF98CF5C7962E13EA7FB20E2 /* Share proxy */ = { + isa = PBXContainerItemProxy; + containerPortal = DEDF09BA2594341999063F5F; + proxyType = 1; + remoteGlobalIDString = C7443E56E1E907B3C23F3D65; + remoteInfo = CrossPointShare; + }; /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -50,6 +66,7 @@ dstPath = ""; dstSubfolderSpec = 13; files = ( + 0E9D3268BD6043FEF43878EF, F507E6B34A0632CF9017FFF2 /* CrossPointWidget.appex in Embed Foundation Extensions */, ); name = "Embed Foundation Extensions"; @@ -99,6 +116,30 @@ F77ADE8FD7A24CA12EFD9819 /* parse_v2.rs */ = {isa = PBXFileReference; path = parse_v2.rs; sourceTree = ""; }; FE54F751F09F6D338C764211 /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; }; FEFB9FFA8A213DFAD642AEFC /* LaunchScreen.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = LaunchScreen.storyboard; sourceTree = ""; }; + 7B070A0995F118DF9B3D65B0 /* ShareViewController.swift */ = { + isa = PBXFileReference; + lastKnownFileType = sourcecode.swift; + path = ShareViewController.swift; + sourceTree = ""; + }; + 461F6177570E98D06DE3B2E6 /* Share Info.plist */ = { + isa = PBXFileReference; + lastKnownFileType = text.plist.xml; + path = Info.plist; + sourceTree = ""; + }; + 4BBF5BA480BC2CBF7A08591F /* CrossPointShare.entitlements */ = { + isa = PBXFileReference; + lastKnownFileType = text.plist.entitlements; + path = CrossPointShare.entitlements; + sourceTree = ""; + }; + 2EF5C746673E376CB9E2CCBB /* CrossPointShare.appex */ = { + isa = PBXFileReference; + explicitFileType = "wrapper.app-extension"; + path = CrossPointShare.appex; + sourceTree = BUILT_PRODUCTS_DIR; + }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -236,6 +277,7 @@ C6909A934DEDCD389C924B62 = { isa = PBXGroup; children = ( + 6028C3C0C414DBDD74685343, E4672BC0025EFAC99DE67520 /* AppIcon.icon */, EFAC7081BE0965C9641C74F2 /* assets */, 72ED45BA68D8C2F5A99C542D /* Assets.xcassets */, @@ -253,6 +295,7 @@ E6507374E28A1DF91D95ECBA /* Products */ = { isa = PBXGroup; children = ( + 2EF5C746673E376CB9E2CCBB, 8379E0BC616B63E8DF220A7F /* crosspoint-sync-app_iOS.app */, BB2A4F2B0B619285749721A7 /* CrossPointWidget.appex */, ); @@ -273,6 +316,16 @@ path = arm64; sourceTree = ""; }; + 6028C3C0C414DBDD74685343 /* CrossPointShare */ = { + isa = PBXGroup; + children = ( + 7B070A0995F118DF9B3D65B0, + 461F6177570E98D06DE3B2E6, + 4BBF5BA480BC2CBF7A08591F, + ); + path = CrossPointShare; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -289,6 +342,7 @@ buildRules = ( ); dependencies = ( + 6340CB7E0B3D7D743F6BB9E5, F74C6A1BA93A3A4914C29977 /* PBXTargetDependency */, ); name = "crosspoint-sync-app_iOS"; @@ -317,6 +371,17 @@ productReference = BB2A4F2B0B619285749721A7 /* CrossPointWidget.appex */; productType = "com.apple.product-type.app-extension"; }; + C7443E56E1E907B3C23F3D65 /* Share target */ = { + isa = PBXNativeTarget; + buildConfigurationList = A434166406DD78CBF6633EDD; + buildPhases = (B64159187A2FBB4BD4268F41, ); + buildRules = (); + dependencies = (); + name = CrossPointShare; + productName = CrossPointShare; + productReference = 2EF5C746673E376CB9E2CCBB; + productType = "com.apple.product-type.app-extension"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -346,6 +411,7 @@ projectDirPath = ""; projectRoot = ""; targets = ( + C7443E56E1E907B3C23F3D65, 68DFB3E8FF3470F98AB93324 /* crosspoint-sync-app_iOS */, F1B6382EBA17716854185CF2 /* CrossPointWidget */, ); @@ -419,6 +485,12 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + B64159187A2FBB4BD4268F41 /* Share Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = (68EF11AFAF17A1B4A324D111, ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ @@ -427,6 +499,11 @@ target = F1B6382EBA17716854185CF2 /* CrossPointWidget */; targetProxy = 914B064C5540432EB389614A /* PBXContainerItemProxy */; }; + 6340CB7E0B3D7D743F6BB9E5 /* Share dependency */ = { + isa = PBXTargetDependency; + target = C7443E56E1E907B3C23F3D65; + targetProxy = CF98CF5C7962E13EA7FB20E2; + }; /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ @@ -659,6 +736,46 @@ }; name = debug; }; + D34BF85984DEA3D3B8F330DC /* Share debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = CrossPointShare/CrossPointShare.entitlements; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = 96247QW4R8; + INFOPLIST_FILE = CrossPointShare/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks"; + PRODUCT_BUNDLE_IDENTIFIER = com.crosspointreader.sync.share; + PRODUCT_NAME = CrossPointShare; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = debug; + }; + 36BD32495DAB854E34E53221 /* Share release */ = { + isa = XCBuildConfiguration; + buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = CrossPointShare/CrossPointShare.entitlements; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = 96247QW4R8; + INFOPLIST_FILE = CrossPointShare/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks"; + PRODUCT_BUNDLE_IDENTIFIER = com.crosspointreader.sync.share; + PRODUCT_NAME = CrossPointShare; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -689,6 +806,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = debug; }; + A434166406DD78CBF6633EDD /* Share configurations */ = { + isa = XCConfigurationList; + buildConfigurations = ( + D34BF85984DEA3D3B8F330DC, + 36BD32495DAB854E34E53221, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = release; + }; /* End XCConfigurationList section */ }; rootObject = DEDF09BA2594341999063F5F /* Project object */; diff --git a/app/src-tauri/gen/apple/crosspoint-sync-app_iOS/Info.plist b/app/src-tauri/gen/apple/crosspoint-sync-app_iOS/Info.plist index ec0e3d3..4117418 100644 --- a/app/src-tauri/gen/apple/crosspoint-sync-app_iOS/Info.plist +++ b/app/src-tauri/gen/apple/crosspoint-sync-app_iOS/Info.plist @@ -72,5 +72,102 @@ UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight + CFBundleDocumentTypes + + + CFBundleTypeName + Reader books, fonts and sleep screens + CFBundleTypeRole + Viewer + LSHandlerRank + Alternate + LSItemContentTypes + + org.idpf.epub-container + com.adobe.adept.acsm + net.daringfireball.markdown + public.plain-text + public.truetype-ttf-font + public.opentype-font + com.crosspointreader.cpfont + public.png + public.jpeg + org.webmproject.webp + com.compuserve.gif + com.microsoft.bmp + + + + UTImportedTypeDeclarations + + + UTTypeIdentifier + org.idpf.epub-container + UTTypeDescription + EPUB book + UTTypeConformsTo + + public.data + + UTTypeTagSpecification + + public.filename-extension + epub + public.mime-type + application/epub+zip + + + + UTTypeIdentifier + com.adobe.adept.acsm + UTTypeDescription + Adobe Content Server Message + UTTypeConformsTo + + public.xml + + UTTypeTagSpecification + + public.filename-extension + acsm + public.mime-type + application/vnd.adobe.adept+xml + + + + UTTypeIdentifier + net.daringfireball.markdown + UTTypeDescription + Markdown document + UTTypeConformsTo + + public.plain-text + + UTTypeTagSpecification + + public.filename-extension + md + public.mime-type + text/markdown + + + + UTTypeIdentifier + com.crosspointreader.cpfont + UTTypeDescription + CrossPoint prebuilt font + UTTypeConformsTo + + public.data + + UTTypeTagSpecification + + public.filename-extension + cpfont + public.mime-type + application/vnd.crosspoint.cpfont + + + - \ No newline at end of file + diff --git a/app/src-tauri/gen/apple/project.yml b/app/src-tauri/gen/apple/project.yml index 5c44c80..e030dc3 100644 --- a/app/src-tauri/gen/apple/project.yml +++ b/app/src-tauri/gen/apple/project.yml @@ -44,6 +44,52 @@ targets: info: path: crosspoint-sync-app_iOS/Info.plist properties: + CFBundleDocumentTypes: + - CFBundleTypeName: Reader books, fonts and sleep screens + CFBundleTypeRole: Viewer + LSHandlerRank: Alternate + LSItemContentTypes: + - org.idpf.epub-container + - com.adobe.adept.acsm + - net.daringfireball.markdown + - public.plain-text + - public.truetype-ttf-font + - public.opentype-font + - com.crosspointreader.cpfont + - public.png + - public.jpeg + - org.webmproject.webp + - com.compuserve.gif + - com.microsoft.bmp + UTImportedTypeDeclarations: + - UTTypeIdentifier: org.idpf.epub-container + UTTypeDescription: EPUB book + UTTypeConformsTo: + - public.data + UTTypeTagSpecification: + public.filename-extension: epub + public.mime-type: application/epub+zip + - UTTypeIdentifier: com.adobe.adept.acsm + UTTypeDescription: Adobe Content Server Message + UTTypeConformsTo: + - public.xml + UTTypeTagSpecification: + public.filename-extension: acsm + public.mime-type: application/vnd.adobe.adept+xml + - UTTypeIdentifier: net.daringfireball.markdown + UTTypeDescription: Markdown document + UTTypeConformsTo: + - public.plain-text + UTTypeTagSpecification: + public.filename-extension: md + public.mime-type: text/markdown + - UTTypeIdentifier: com.crosspointreader.cpfont + UTTypeDescription: CrossPoint prebuilt font + UTTypeConformsTo: + - public.data + UTTypeTagSpecification: + public.filename-extension: cpfont + public.mime-type: application/vnd.crosspoint.cpfont LSRequiresIPhoneOS: true UILaunchStoryboardName: LaunchScreen UIRequiredDeviceCapabilities: [arm64, metal] @@ -81,7 +127,7 @@ targets: properties: com.apple.security.application-groups: [group.com.crosspointreader.sync] # Universal links for connector sign-in callbacks (server serves the AASA file). - com.apple.developer.associated-domains: [applinks:sync.crosspointreader.com] + com.apple.developer.associated-domains: ["applinks:sync.crosspointreader.com"] scheme: environmentVariables: RUST_BACKTRACE: full @@ -99,6 +145,9 @@ targets: EXCLUDED_ARCHS[sdk=iphoneos*]: x86_64 groups: [app] dependencies: + - target: CrossPointShare + embed: true + codeSign: true - target: CrossPointWidget embed: true codeSign: true @@ -155,3 +204,75 @@ targets: - sdk: WidgetKit.framework - sdk: SwiftUI.framework + # Incoming shares queue files in the App Group even with the app closed. + CrossPointShare: + type: app-extension + platform: iOS + deploymentTarget: "16.0" + sources: + - path: CrossPointShare + excludes: [Info.plist, CrossPointShare.entitlements] + info: + path: CrossPointShare/Info.plist + properties: + CFBundleDisplayName: CrossPoint Sync + CFBundleShortVersionString: 0.1.2 + CFBundleVersion: "28" + NSExtension: + NSExtensionPointIdentifier: com.apple.share-services + NSExtensionPrincipalClass: "$(PRODUCT_MODULE_NAME).ShareViewController" + NSExtensionAttributes: + NSExtensionActivationRule: SUBQUERY(extensionItems, $item, SUBQUERY($item.attachments, + $attachment, ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.file-url" + OR ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "org.idpf.epub-container" + OR ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "com.adobe.adept.acsm" + OR ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.plain-text" + OR ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "com.crosspointreader.cpfont" OR ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.font" OR + ANY $attachment.registeredTypeIdentifiers UTI-CONFORMS-TO "public.image").@count + > 0).@count > 0 + UTImportedTypeDeclarations: + - UTTypeIdentifier: org.idpf.epub-container + UTTypeDescription: EPUB book + UTTypeConformsTo: + - public.data + UTTypeTagSpecification: + public.filename-extension: epub + public.mime-type: application/epub+zip + - UTTypeIdentifier: com.adobe.adept.acsm + UTTypeDescription: Adobe Content Server Message + UTTypeConformsTo: + - public.xml + UTTypeTagSpecification: + public.filename-extension: acsm + public.mime-type: application/vnd.adobe.adept+xml + - UTTypeIdentifier: net.daringfireball.markdown + UTTypeDescription: Markdown document + UTTypeConformsTo: + - public.plain-text + UTTypeTagSpecification: + public.filename-extension: md + public.mime-type: text/markdown + - UTTypeIdentifier: com.crosspointreader.cpfont + UTTypeDescription: CrossPoint prebuilt font + UTTypeConformsTo: + - public.data + UTTypeTagSpecification: + public.filename-extension: cpfont + public.mime-type: application/vnd.crosspoint.cpfont + entitlements: + path: CrossPointShare/CrossPointShare.entitlements + properties: + com.apple.security.application-groups: [group.com.crosspointreader.sync] + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: com.crosspointreader.sync.share + DEVELOPMENT_TEAM: 96247QW4R8 + CODE_SIGN_STYLE: Automatic + PRODUCT_NAME: CrossPointShare + SWIFT_VERSION: "5.0" + TARGETED_DEVICE_FAMILY: "1,2" + APPLICATION_EXTENSION_API_ONLY: YES + SKIP_INSTALL: true + dependencies: + - sdk: UIKit.framework + - sdk: UniformTypeIdentifiers.framework diff --git a/app/src-tauri/src/books.rs b/app/src-tauri/src/books.rs index 29bafa5..c24d883 100644 --- a/app/src-tauri/src/books.rs +++ b/app/src-tauri/src/books.rs @@ -186,7 +186,7 @@ pub async fn send_download( /// Raw IPC body bytes. Android's WebView can't expose request bodies to the IPC /// protocol, so Tauri JSON-encodes the message there; the app sends base64 text /// (ipcBytes in api.js), and a plain byte array still works as a fallback. -fn body_bytes(request: &Request<'_>) -> Res> { +pub(crate) fn body_bytes(request: &Request<'_>) -> Res> { use base64::{engine::general_purpose::STANDARD, Engine}; match request.body() { InvokeBody::Raw(bytes) => Ok(bytes.clone()), @@ -358,7 +358,7 @@ fn urlencoding_encode(s: &str) -> String { .collect() } -fn urlencoding_decode(s: &str) -> Option { +pub(crate) fn urlencoding_decode(s: &str) -> Option { let bytes = s.as_bytes(); let mut out = Vec::with_capacity(bytes.len()); let mut i = 0; diff --git a/app/src-tauri/src/coordinated_import.rs b/app/src-tauri/src/coordinated_import.rs new file mode 100644 index 0000000..fd6849f --- /dev/null +++ b/app/src-tauri/src/coordinated_import.rs @@ -0,0 +1,131 @@ +//! Security scope is acquired at URL delivery and owned until the worker is done. +use objc2::rc::Retained; +use objc2_foundation::{NSString, NSURL}; +use std::path::Path; + +// All Open In workers share one slot, including workers from separate batches. +static IMPORT_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + +pub struct ScopedSource { + url: Retained, + accessed: bool, +} +impl ScopedSource { + pub fn acquire(url: &url::Url) -> Result { + if url.scheme() != "file" { return Err("Expected a file URL".into()); } + let native = NSURL::URLWithString(&NSString::from_str(url.as_str())).ok_or("Invalid file URL")?; + let accessed = unsafe { native.startAccessingSecurityScopedResource() }; + Ok(Self { url: native, accessed }) + } + + pub fn import_into(&self, root: &Path, name: &str) -> Result { + use objc2_foundation::{NSFileCoordinator, NSFileCoordinatorReadingOptions}; + use std::{cell::RefCell, ptr::NonNull}; + // Acquire before coordination/reading and retain through publication and + // buffer disposal. The worker continues owning its source grant while waiting. + // This mutex protects scheduling only, so a prior panic cannot corrupt state. + let _serial = IMPORT_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let outcome = RefCell::new(None); + let reader = block2::StackBlock::new(|readable: NonNull| { + // The coordinator may supply a different URL; read only its accessor URL + // and finish reading before relinquishing the provider's coordinated access. + let readable = unsafe { readable.as_ref() }; + let result = readable.path().ok_or_else(|| "Invalid coordinated file URL".to_string()) + .and_then(|path| std::fs::read(path.to_string()).map_err(|e| e.to_string())); + *outcome.borrow_mut() = Some(result); + }); + let mut error = None; + NSFileCoordinator::new().coordinateReadingItemAtURL_options_error_byAccessor( + &self.url, NSFileCoordinatorReadingOptions::empty(), Some(&mut error), &reader, + ); + if let Some(error) = error { return Err(error.localizedDescription().to_string()); } + let bytes = outcome.into_inner().ok_or("The file provider did not supply readable contents")??; + crate::import_store::add(root, name, &bytes).map_err(|e| e.to_string()) + } +} +impl Drop for ScopedSource { + fn drop(&mut self) { + if self.accessed { unsafe { self.url.stopAccessingSecurityScopedResource() }; } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use objc2::rc::autoreleasepool; + use objc2_foundation::{NSFileCoordinator, NSFileCoordinatorWritingOptions}; + use std::{fs, ptr::NonNull, sync::mpsc, thread, time::{Duration, SystemTime, UNIX_EPOCH}}; + + fn root() -> std::path::PathBuf { + let path = std::env::temp_dir().join(format!("crosspoint-coordination-{}-{}",std::process::id(),SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos())); + fs::create_dir_all(&path).unwrap(); path + } + + #[test] + fn waits_for_a_coordinated_writer_and_serializes_other_imports() { + autoreleasepool(|_| { + let root = root(); + let input = root.join("source.epub"); + let queue = root.join("queue"); + fs::write(&input,b"old partial content").unwrap(); + let url = url::Url::from_file_path(&input).unwrap(); + let source = ScopedSource::acquire(&url).unwrap(); + let native = source.url.clone(); + let (locked_tx,locked_rx) = mpsc::channel(); + let (release_tx,release_rx) = mpsc::channel(); + let writer = thread::spawn(move || autoreleasepool(|_| { + let callback = block2::StackBlock::new(|_: NonNull| { + locked_tx.send(()).unwrap(); + release_rx.recv().unwrap(); + fs::write(&input,b"complete provider content").unwrap(); + }); + let mut error = None; + NSFileCoordinator::new().coordinateWritingItemAtURL_options_error_byAccessor(&native,NSFileCoordinatorWritingOptions::empty(),Some(&mut error),&callback); + assert!(error.is_none(),"{error:?}"); + })); + locked_rx.recv_timeout(Duration::from_secs(5)).unwrap(); + let (done_tx,done_rx) = mpsc::channel(); + let destination = queue.clone(); + let reader = thread::spawn(move || autoreleasepool(|_| { + done_tx.send(source.import_into(&destination,"book.epub")).unwrap(); + })); + let early = done_rx.recv_timeout(Duration::from_millis(150)); + let before_publish = crate::import_store::list(&queue).unwrap(); + // While the first import waits on its provider, a different source must + // not start buffering its contents or publish ahead of that import. + let second_path = root.join("second.txt"); + fs::write(&second_path, b"second complete file").unwrap(); + let second = ScopedSource::acquire(&url::Url::from_file_path(&second_path).unwrap()).unwrap(); + let destination = queue.clone(); + let (second_tx, second_rx) = mpsc::channel(); + let second_reader = thread::spawn(move || autoreleasepool(|_| { + second_tx.send(second.import_into(&destination, "second.txt")).unwrap(); + })); + let concurrent = second_rx.recv_timeout(Duration::from_millis(150)); + release_tx.send(()).unwrap(); + writer.join().unwrap(); + reader.join().unwrap(); + second_reader.join().unwrap(); + let second_result = if let Ok(result) = concurrent.as_ref() { result.as_ref().unwrap().clone() } else { second_rx.recv_timeout(Duration::from_secs(5)).unwrap().unwrap() }; + let result = if let Ok(result) = early.as_ref() { result.as_ref().unwrap().clone() } else { done_rx.recv_timeout(Duration::from_secs(5)).unwrap().unwrap() }; + assert!(early.is_err(),"import completed while the provider still owned its write"); + assert!(before_publish.is_empty(),"partial provider content was published"); + assert!(concurrent.is_err(), "a second import buffered and published while the first was still coordinating"); + assert_eq!(crate::import_store::read(&queue,&second_result.id).unwrap(),b"second complete file"); + assert_eq!(crate::import_store::read(&queue,&result.id).unwrap(),b"complete provider content"); + fs::remove_dir_all(root).unwrap(); + }); + } + + #[test] + fn a_failed_provider_read_leaves_no_queue_entry() { + autoreleasepool(|_| { + let root = root(); + let source = ScopedSource::acquire(&url::Url::from_file_path(root.join("missing.epub")).unwrap()).unwrap(); + let queue = root.join("queue"); + assert!(source.import_into(&queue,"missing.epub").is_err()); + assert!(crate::import_store::list(&queue).unwrap().is_empty()); + fs::remove_dir_all(root).unwrap(); + }); + } +} diff --git a/app/src-tauri/src/import_store.rs b/app/src-tauri/src/import_store.rs new file mode 100644 index 0000000..5363059 --- /dev/null +++ b/app/src-tauri/src/import_store.rs @@ -0,0 +1,102 @@ +//! On-disk interchange with Android and the iOS Share extension. A committed +//! entry is a unique directory containing one original-named file; dot-prefixed +//! staging directories are invisible until the producer atomically renames them. +use std::{fs, io, path::{Path, PathBuf}, sync::atomic::{AtomicU64, Ordering}, time::{SystemTime, UNIX_EPOCH}}; + +#[derive(Debug, Clone)] +pub struct Entry { pub id: String, pub name: String, pub size: u64 } +fn invalid() -> io::Error { io::Error::new(io::ErrorKind::InvalidInput, "Invalid queued file") } +pub fn valid_name(name: &str) -> bool { + !name.is_empty() && !name.starts_with('.') && !name.chars().any(|c| c == '/' || c == '\\' || c.is_control()) && + name.rsplit_once('.').is_some_and(|(_, ext)| ["epub", "acsm", "md", "txt", "ttf", "otf", "cpfont", "png", "jpg", "jpeg", "webp", "gif", "bmp"].contains(&ext.to_ascii_lowercase().as_str())) +} +fn entry_dir(root: &Path, id: &str) -> io::Result { + if id.is_empty() || !id.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-') { return Err(invalid()); } + let dir = root.join(id); + if fs::symlink_metadata(&dir)?.file_type().is_symlink() { return Err(invalid()); } + Ok(dir) +} +pub fn list(root: &Path) -> io::Result> { + fs::create_dir_all(root)?; + let mut entries = Vec::new(); + for item in fs::read_dir(root)? { + let item = item?; + let id = item.file_name().to_string_lossy().into_owned(); + let Ok(dir) = entry_dir(root, &id) else { continue }; + if !item.file_type()?.is_dir() { continue } + // Another consumer may finish/remove an entry during a list operation. + let Ok(contents) = fs::read_dir(dir) else { continue }; + for file in contents { + let Ok(file) = file else { continue }; + let name = file.file_name().to_string_lossy().into_owned(); + if valid_name(&name) && file.file_type().is_ok_and(|t| t.is_file()) { + let Ok(meta) = file.metadata() else { continue }; + entries.push(Entry { id: id.clone(), name, size: meta.len() }); + break; + } + } + } + entries.sort_by(|a, b| a.id.cmp(&b.id)); + Ok(entries) +} +pub fn read(root: &Path, id: &str) -> io::Result> { + let dir = entry_dir(root, id)?; + let entry = list(root)?.into_iter().find(|e| e.id == id).ok_or_else(invalid)?; + fs::read(dir.join(entry.name)) +} +pub fn remove(root: &Path, id: &str) -> io::Result<()> { + let dir = entry_dir(root, id)?; + fs::remove_dir_all(dir) +} +pub fn add(root: &Path, name: &str, bytes: &[u8]) -> io::Result { + if !valid_name(name) { return Err(invalid()) } + fs::create_dir_all(root)?; + static NEXT: AtomicU64 = AtomicU64::new(0); + let id = format!("{}-{}-{}", SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_nanos(), std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed)); + let temp = root.join(format!(".{id}")); + fs::create_dir(&temp)?; + let result = (|| { + fs::write(temp.join(name), bytes)?; + fs::rename(&temp, root.join(&id))?; + Ok(Entry { id, name: name.into(), size: bytes.len() as u64 }) + })(); + if result.is_err() { let _ = fs::remove_dir_all(temp); } + result +} + +#[cfg(test)] +mod tests { + use super::*; + fn root() -> PathBuf { + let root = std::env::temp_dir().join(format!("crosspoint-import-test-{}-{:?}", std::process::id(), std::thread::current().id())); + let _ = fs::remove_dir_all(&root); + fs::create_dir_all(&root).unwrap(); root + } + #[test] + fn same_names_survive_restart_and_independent_removal() { + let root = root(); + let first = add(&root, "Été.EPUB", b"one").unwrap(); + let second = add(&root, "Été.EPUB", b"two").unwrap(); + assert_eq!(list(&root).unwrap().len(), 2); + assert_eq!(read(&root, &first.id).unwrap(), b"one"); + remove(&root, &first.id).unwrap(); + assert_eq!(read(&root, &second.id).unwrap(), b"two"); + assert_eq!(list(&root).unwrap().len(), 1); + fs::remove_dir_all(root).unwrap(); + } + #[test] + fn rejects_traversal_unsupported_files_and_incomplete_copies() { + let root = root(); + for name in ["../book.epub", "folder/book.txt", "a\\b.md", ".hidden.epub", "photo.heic", "bad\n.txt"] { + assert!(add(&root, name, b"bad").is_err(), "{name}"); + } + fs::create_dir(root.join(".partial")).unwrap(); + fs::write(root.join(".partial/book.epub"), b"partial").unwrap(); + assert!(list(&root).unwrap().is_empty()); + assert!(read(&root, "../elsewhere").is_err()); + assert!(remove(&root, "..").is_err()); + for name in ["book.epub", "loan.acsm", "notes.md", "notes.TXT", "font.TTF", "font.otf", "font.cpfont", "sleep.PNG", "sleep.jpg", "sleep.jpeg", "sleep.webp", "sleep.gif", "sleep.bmp"] { add(&root, name, b"ok").unwrap(); } + assert_eq!(list(&root).unwrap().len(), 13); + fs::remove_dir_all(root).unwrap(); + } +} diff --git a/app/src-tauri/src/imports.rs b/app/src-tauri/src/imports.rs new file mode 100644 index 0000000..5e85e8c --- /dev/null +++ b/app/src-tauri/src/imports.rs @@ -0,0 +1,64 @@ +use crate::import_store; +use tauri::{AppHandle, ipc::Request}; +#[cfg(not(target_os = "ios"))] +use tauri::Manager; +use std::path::PathBuf; + +fn queue_dir(_app: &AppHandle) -> Result { + #[cfg(target_os = "ios")] + { return crate::widget::group_dir().map(|p| p.join("ImportQueue")).ok_or("App Group container unavailable".into()); } + #[cfg(not(target_os = "ios"))] + { Ok(_app.path().app_data_dir().map_err(|e| e.to_string())?.join("ImportQueue")) } +} +#[derive(serde::Serialize)] +pub struct Import { id: String, name: String, size: u64 } +impl From for Import { + fn from(e: import_store::Entry) -> Self { Self { id: e.id, name: e.name, size: e.size } } +} +#[tauri::command] +pub async fn list_imports(app: AppHandle) -> Result, String> { + import_store::list(&queue_dir(&app)?).map(|v| v.into_iter().map(Into::into).collect()).map_err(|e| e.to_string()) +} +#[tauri::command] +pub async fn add_import(app: AppHandle, request: Request<'_>) -> Result { + let name = request.headers().get("x-name").and_then(|v| v.to_str().ok()) + .and_then(crate::books::urlencoding_decode).ok_or("Missing file name")?; + let bytes = crate::books::body_bytes(&request)?; + import_store::add(&queue_dir(&app)?, &name, &bytes).map(Into::into).map_err(|e| e.to_string()) +} +#[tauri::command] +pub async fn read_import(app: AppHandle, id: String) -> Result { + import_store::read(&queue_dir(&app)?, &id).map(tauri::ipc::Response::new).map_err(|e| e.to_string()) +} +#[tauri::command] +pub async fn remove_import(app: AppHandle, id: String) -> Result<(), String> { + import_store::remove(&queue_dir(&app)?, &id).map_err(|e| e.to_string()) +} + +// Acquire and retain the source grant before returning to the event loop. Both +// provider coordination (which can wait for iCloud) and disk I/O run on a worker. +#[cfg(target_os = "ios")] +pub fn open(app: &AppHandle, url: &url::Url) { + use crate::coordinated_import::ScopedSource; + use tauri::Emitter; + if url.scheme() != "file" { return } + let prepare = (|| -> Result<_, String> { + let path = url.to_file_path().map_err(|_| "Invalid file URL")?; + let name = path.file_name().and_then(|s| s.to_str()).ok_or("Invalid file name")?.to_owned(); + if !import_store::valid_name(&name) { return Err("Choose books, TTF/OTF/CPFONT fonts, or PNG/JPG/WebP/GIF/BMP sleep screens".into()); } + Ok((ScopedSource::acquire(url)?, name)) + })(); + match prepare { + Err(error) => { let _ = app.emit("import-error", error); } + Ok((source, name)) => { + let app = app.clone(); + tauri::async_runtime::spawn_blocking(move || objc2::rc::autoreleasepool(|_| { + let result = queue_dir(&app).and_then(|root| source.import_into(&root, &name)); + // source owns its security grant until this worker completes, including + // any failed coordination/read/publication path (released by Drop). + if let Err(error) = result { let _ = app.emit("import-error", error); } + drop(source); // release the scope inside the worker autorelease pool + })); + } + } +} diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index 4e25415..90ee25a 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -1,4 +1,8 @@ mod books; +mod import_store; +mod imports; +#[cfg(any(target_os = "ios", all(test, target_os = "macos")))] +mod coordinated_import; mod epub_name; mod mdns; mod opds; @@ -29,6 +33,10 @@ pub fn run() { Ok(()) }) .invoke_handler(tauri::generate_handler![ + imports::list_imports, + imports::add_import, + imports::read_import, + imports::remove_import, mdns::resolve_local, books::opds_feed, books::opds_search, @@ -51,8 +59,14 @@ pub fn run() { set_page_scroll, refresh::end_refresh, ]) - .run(tauri::generate_context!()) - .expect("error while running tauri application"); + .build(tauri::generate_context!()) + .expect("error while building tauri application") + .run(|_app, _event| { + #[cfg(target_os = "ios")] + if let tauri::RunEvent::Opened { urls } = _event { + for url in urls { imports::open(_app, &url); } + } + }); } // Read the real OS version; WebKit's user agent can report a compatibility version. diff --git a/app/src-tauri/src/widget.rs b/app/src-tauri/src/widget.rs index 0b4bdc5..577d911 100644 --- a/app/src-tauri/src/widget.rs +++ b/app/src-tauri/src/widget.rs @@ -54,7 +54,7 @@ extern "C" { #[cfg(target_os = "ios")] #[allow(unused_unsafe)] -fn group_dir() -> Option { +pub(crate) fn group_dir() -> Option { use objc2_foundation::{NSFileManager, NSString}; let manager = unsafe { NSFileManager::defaultManager() }; let url = unsafe { manager.containerURLForSecurityApplicationGroupIdentifier(&NSString::from_str("group.com.crosspointreader.sync")) }?; diff --git a/app/src/App.jsx b/app/src/App.jsx index 4786417..68d2b02 100644 --- a/app/src/App.jsx +++ b/app/src/App.jsx @@ -2,10 +2,13 @@ import { useEffect, useRef, useState } from 'react' import { invoke } from '@tauri-apps/api/core' import { getCurrent, onOpenUrl } from '@tauri-apps/plugin-deep-link' import { widgetRoute } from './widgetLinks.js' +import { watchImports } from './importQueue.js' +import { importRoute } from './importTypes.js' +import { listen } from '@tauri-apps/api/event' import { ChartColumn, CloudOff, Compass, MessageSquareQuote, Eye, EyeOff, LibraryBig, Loader2, Lock, Send as SendIcon, Server, Settings as SettingsIcon, ShieldCheck, User } from 'lucide-react' import { useTheme } from './theme.js' import { DEFAULT_SERVER, syncShortcutsCache, api, hostedServer, isApp, lastServer, loadSession, login, logout, offline, cached, recover, register, saveSession } from './api.js' -import { ErrorNote, LoadCacheProvider, PageSkeleton, RefreshPill, Toaster, useLoad } from './ui.jsx' +import { ErrorNote, LoadCacheProvider, PageSkeleton, RefreshPill, Toaster, useLoad, notify } from './ui.jsx' import Library from './Library.jsx' import Book from './Book.jsx' import Stats from './Stats.jsx' @@ -392,7 +395,25 @@ function Sidebar({ route, session, nav = NAV }) { ) } +function useIncomingFiles() { + useEffect(() => { + if (!isApp) return + let lastError + const report = error => { + const detail = String(error) + if (detail === lastError) return + lastError = detail + notify({ error: true, title: "Couldn't import files", detail }) + } + const stop = watchImports(entries => { location.hash = importRoute(entries) }, report) + let live = true, unlisten + listen('import-error', event => report(event.payload)).then(fn => { if (live) unlisten = fn; else fn() }).catch(report) + return () => { live = false; stop(); unlisten?.() } + }, []) +} + function Home({ session, onSession, onLogout, theme }) { + useIncomingFiles() useEffect(() => { if (!isApp) return let live = true, stop @@ -535,6 +556,7 @@ function Home({ session, onSession, onLogout, theme }) { } function Guest({ theme, onSignIn }) { + useIncomingFiles() const [parts] = useHash() const [route, id] = parts const page = route === 'send' ? diff --git a/app/src/ImportedAssets.jsx b/app/src/ImportedAssets.jsx new file mode 100644 index 0000000..7e1e51b --- /dev/null +++ b/app/src/ImportedAssets.jsx @@ -0,0 +1,69 @@ +import { useEffect, useState } from 'react' +import { listImports, readImport, removeImport } from './importQueue.js' +import { isApp } from './api.js' +import { importKind } from './importTypes.js' +import { Card, notify } from './ui.jsx' + +// Shared between mounted editors, so navigating away cannot start a second +// upload of the same file. Native copies remain authoritative across restarts. +const active = new Set() +const changed = () => window.dispatchEvent(new Event('crosspoint-imports-changed')) +export async function runImportedAsset(id, action, removeAfter = true) { + const ids = Array.isArray(id) ? id : [id] + if (ids.some(id => active.has(id))) throw new Error('This file is already being processed') + for (const id of ids) active.add(id) + changed() + try { + const result = await action() + if (removeAfter) for (const id of ids) await removeImport(id) + return result + } finally { for (const id of ids) active.delete(id); changed() } +} + +export default function ImportedAssets({ kind, actionLabel, onAction, actionDisabled = false, removalDisabled = false, removeAfter = true, groupEntries, onRemoved }) { + const [entries, setEntries] = useState([]) + const [jobs, setJobs] = useState(new Set()) + useEffect(() => { + if (!isApp) return + let live = true, request = 0 + const load = async () => { + const current = ++request + setJobs(new Set(active)) + try { + const all = await listImports() + if (live && current === request) setEntries(all.filter(e => importKind(e) === kind)) + } catch (error) { if (live) notify({ error: true, title: "Couldn't load queued files", detail: String(error) }) } + } + load() + window.addEventListener('crosspoint-imports-changed', load) + return () => { live = false; window.removeEventListener('crosspoint-imports-changed', load) } + }, [kind]) + async function act(entry, remove = false) { + try { + const batch = !remove && groupEntries ? groupEntries(entry, entries) : [entry] + await runImportedAsset(batch.map(e => e.id), async () => { + if (!remove) { + const files = await Promise.all(batch.map(readImport)) + await onAction(files[0], entry, files) + } + }, remove || removeAfter) + if (remove) onRemoved?.(entry) + } catch (error) { notify({ error: true, title: `Couldn't ${remove ? 'remove' : 'process'} ${entry.name}`, detail: String(error) }) } + } + if (!entries.length) return null + return ( +
+

Shared files waiting to send

+ {groupEntries &&

Available styles from the same font family send together.

} + + {entries.map(entry => ( +
+ {entry.name} + + +
+ ))} +
+
+ ) +} diff --git a/app/src/ImportedAssets.test.jsx b/app/src/ImportedAssets.test.jsx new file mode 100644 index 0000000..873cd50 --- /dev/null +++ b/app/src/ImportedAssets.test.jsx @@ -0,0 +1,65 @@ +// @vitest-environment jsdom +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +vi.mock('@tauri-apps/api/core', () => ({ invoke: vi.fn() })) +vi.mock('./api.js', () => ({ isApp: true, ipcBytes: vi.fn() })) +vi.mock('./ui.jsx', async original => ({ ...await original(), notify: vi.fn() })) +import { invoke } from '@tauri-apps/api/core' +import ImportedAssets from './ImportedAssets.jsx' +let entries +beforeEach(() => { + entries = [{id:'font',name:'Reader.ttf',size:2},{id:'image',name:'Sleep.png',size:2},{id:'book',name:'Book.epub',size:2}] + invoke.mockImplementation(async (cmd,args) => { + if(cmd === 'list_imports') return entries.map(e=>({...e})) + if(cmd === 'read_import') return [1,2] + if(cmd === 'remove_import') { entries=entries.filter(e=>e.id!==args.id);return } + throw Error(cmd) + }) +}) +afterEach(() => { cleanup(); vi.clearAllMocks() }) +it('filters categories and preserves a failed send, then removes only a successful font', async () => { + let fails=true + const action=async file=>{ expect(file.name).toBe('Reader.ttf');if(fails)throw Error('Disconnected') } + render( ) + await screen.findByText('Reader.ttf') + expect(screen.queryByText('Book.epub')).toBeNull() + expect(screen.queryByText('Sleep.png')).toBeNull() + fireEvent.click(screen.getByRole('button',{name:'Send font'})) + await waitFor(()=>expect(screen.getByRole('button',{name:'Send font'}).disabled).toBe(false)) + expect(entries.map(e=>e.id)).toEqual(['font','image','book']) + fails=false + fireEvent.click(screen.getByRole('button',{name:'Send font'})) + await waitFor(()=>expect(screen.queryByText('Reader.ttf')).toBeNull()) + expect(entries.map(e=>e.id)).toEqual(['image','book']) +}) +it('editing a sleep screen retains the original across remount, and Remove deletes it', async () => { + const view=render({}} removeAfter={false}/>) + fireEvent.click(await screen.findByRole('button',{name:'Edit'})) + await waitFor(()=>expect(screen.getByRole('button',{name:'Edit'}).disabled).toBe(false)) + view.unmount() + render({}} removeAfter={false}/>) + fireEvent.click(await screen.findByRole('button',{name:'Remove Sleep.png'})) + await waitFor(()=>expect(screen.queryByText('Sleep.png')).toBeNull()) + expect(entries.map(e=>e.id)).toEqual(['font','book']) +}) +it('locks and consumes all related font faces only after the grouped upload succeeds',async()=>{ + entries.push({id:'bold',name:'Reader-Bold.ttf',size:2}) + let finish + const action=vi.fn(async(_file,_entry,files)=>{expect(files.map(f=>f.name)).toEqual(['Reader.ttf','Reader-Bold.ttf']);await new Promise(done=>{finish=done})}) + render(all}/>) + fireEvent.click((await screen.findAllByRole('button',{name:'Send family'}))[0]) + await waitFor(()=>expect(finish).toBeTypeOf('function')) + expect(entries).toHaveLength(4) + expect(screen.getAllByRole('button',{name:'Processing…'})).toHaveLength(2) + finish() + await waitFor(()=>expect(screen.queryByText('Reader.ttf')).toBeNull()) + expect(entries.map(e=>e.id)).toEqual(['image','book']) +}) +it('allows removing an offline font while its Send action is disabled',async()=>{ + render() + expect((await screen.findByRole('button',{name:'Send'})).disabled).toBe(true) + const remove=screen.getByRole('button',{name:'Remove Reader.ttf'}) + expect(remove.disabled).toBe(false) + fireEvent.click(remove) + await waitFor(()=>expect(screen.queryByText('Reader.ttf')).toBeNull()) +}) diff --git a/app/src/ReaderFonts.jsx b/app/src/ReaderFonts.jsx index db547ad..70265ea 100644 --- a/app/src/ReaderFonts.jsx +++ b/app/src/ReaderFonts.jsx @@ -3,6 +3,8 @@ import { ChevronRight, Download, FileUp, Loader2, Search, Send as SendIcon, Tras import { deleteFont, listFonts, uploadFont, uploadTrueType } from './device.js' import { convert, downloadFamily, familyName, googleFonts, localFamily, trueTypeFiles } from './fonts/google.js' import { cpfontFamilies, crossPointFonts, downloadPrebuilt, forgetFont, isPrebuilt, readFont, saveFont, savedFonts } from './fonts/saved.js' +import ImportedAssets from './ImportedAssets.jsx' +import { groupFontEntries, sendImportedFonts } from './fonts/sendImported.js' import { ActionSheet, Card, EmptyState, notify, Toggle } from './ui.jsx' // X3/X4 (ESP32-C3, no external RAM) need .cpfont; ESP32-S3 readers load TrueType directly. @@ -145,10 +147,20 @@ export default function ReaderFonts({ base, device }) { setTick((t) => t + 1) }, `Couldn't send ${font.family}`) + const sendShared = async (file, _entry, files) => { + try { + setJob({ family: file.name, label: 'Sending…' }) + await sendImportedFonts(files, { base, trueType, progress: label => setJob({ family: file.name, label }) }) + setTick(t => t + 1) + notify({ title: `Sent ${file.name}`, detail: 'Pick it in Settings › Reader › Font Family' }) + } finally { setJob(null) } + } + const status = (family, fallback) => (job?.family === family ? job.label : fallback) return (
+

Get fonts any time, then send them when your reader is connected.

))} -
-

Convert to .cpfont

- setTrueType(!convert)} /> -
+ )} +
+

Convert to .cpfont

+ setTrueType(!value)} /> +
+

On your reader

{!base ? ( diff --git a/app/src/Send.jsx b/app/src/Send.jsx index 64a6f6d..cde8a42 100644 --- a/app/src/Send.jsx +++ b/app/src/Send.jsx @@ -23,6 +23,8 @@ import { DEFAULT_HOST, connect, deleteFiles, folders, isBook, joinPath, listFile import { downloads as listDownloads, removeDownload, sendDownload, sendFile } from './catalogs.js' import { ActionSheet, Card, EmptyState, folderLabel, notify, Tabs, Toggle } from './ui.jsx' import ConversionLog from './ConversionLog.jsx' +import { importKind } from './importTypes.js' +import { listImports, addImport, readImport, removeImport } from './importQueue.js' import Plugins from './Plugins.jsx' import ReaderFonts from './ReaderFonts.jsx' import Wallpaper from './Wallpaper.jsx' @@ -400,6 +402,8 @@ function ReaderFiles({ base, onFoldersChanged }) { ) } +const activeImports = new Set() + const TABS = [ ['', 'Books'], ['fonts', 'Fonts'], @@ -412,10 +416,12 @@ export default function Send({ tab = '' }) { const [device, setDevice] = useState({ state: 'searching', dirs: [] }) const [attempt, setAttempt] = useState(0) const [files, setFiles] = useState([]) // { file, state: 'queued'|'sending'|'done'|'error', error } + const [importCounts, setImportCounts] = useState({}) const [skipped, setSkipped] = useState(0) const [sending, setSending] = useState(false) const [dragging, setDragging] = useState(false) const input = useRef(null) + const queueRevision = useRef(0) const setPrefs = (p) => { setPrefsState(p) saveDevicePrefs(p) @@ -436,31 +442,99 @@ export default function Send({ tab = '' }) { } }, [prefs.host, attempt]) - function add(list) { + useEffect(() => { + if (!isApp) return + let live = true, latest = 0, retry, reported = false + const schedule = delay => { + clearTimeout(retry) + retry = setTimeout(load, delay) + } + const load = async () => { + clearTimeout(retry) + const request = ++latest + const revision = queueRevision.current + try { + const all = await listImports() + if (!live || request !== latest) return + if (revision !== queueRevision.current) { schedule(0); return } + reported = false + const entries = all.filter(isBook) + setImportCounts(all.reduce((counts, entry) => { + const kind = importKind(entry) + if (kind) counts[kind] = (counts[kind] ?? 0) + 1 + return counts + }, {})) + setFiles(cur => { + if (revision !== queueRevision.current) return cur + const byId = new Map(cur.map(item => [item.id, item])) + return entries.map(entry => { + const current = byId.get(entry.id) + if (activeImports.has(entry.id)) return current ?? { id: entry.id, file: entry, state: 'sending' } + return current?.state !== 'sending' && current ? current : { id: entry.id, file: entry, state: 'queued' } + }) + }) + } catch (error) { + if (!live || request !== latest) return + if (!reported) notify({ error: true, title: "Couldn't load queued files", detail: String(error) }) + reported = true + schedule(1000) + } + } + load() + window.addEventListener('crosspoint-imports-changed', load) + return () => { live = false; clearTimeout(retry); window.removeEventListener('crosspoint-imports-changed', load) } + }, []) + + async function add(list) { const all = [...list] const books = all.filter(isBook) setSkipped(all.length - books.length) - setFiles((cur) => [ - ...cur.filter((f) => f.state !== 'done'), - ...books.filter((b) => !cur.some((f) => f.file.name === b.name)).map((file) => ({ file, state: 'queued' })), - ]) + for (const file of books) { + try { + const entry = await addImport(file) + queueRevision.current += 1 + setFiles(cur => cur.some(item => item.id === entry.id) ? cur : [...cur, { id: entry.id, file: entry, state: 'queued' }]) + window.dispatchEvent(new Event('crosspoint-imports-changed')) + } catch (error) { + notify({ error: true, title: `Couldn't queue ${file.name}`, detail: String(error) }) + } + } + } + + async function remove(item) { + try { + await removeImport(item.id) + queueRevision.current += 1 + setFiles(cur => cur.filter(f => f.id !== item.id)) + window.dispatchEvent(new Event('crosspoint-imports-changed')) + } catch (error) { + notify({ error: true, title: "Couldn't remove queued file", detail: String(error) }) + } } async function sendAll() { + const batch = files.filter(item => item.state !== 'done' && !activeImports.has(item.id)) + if (!batch.length) return + for (const item of batch) activeImports.add(item.id) setSending(true) const sent = [] const failed = [] - for (const item of files) { - if (item.state === 'done') continue + for (const item of batch) { const update = (patch) => setFiles((cur) => cur.map((f) => (f.file === item.file ? { ...f, ...patch } : f))) update({ state: 'sending', error: null, progress: null, logs: [] }) try { - const used = await sendFile(item.file, prefs, (event) => setFiles((cur) => cur.map((f) => f.file === item.file ? transferProgress(f, event) : f))) + const file = await readImport({ ...item.file, id: item.id }) + const used = await sendFile(file, prefs, (event) => setFiles((cur) => cur.map((f) => f.file === item.file ? transferProgress(f, event) : f))) + await removeImport(item.id) + queueRevision.current += 1 update({ state: 'done', sentAs: used !== item.file.name ? used : null }) sent.push(used) } catch (e) { update({ state: 'error', error: String(e) }) failed.push(String(e)) + } finally { + activeImports.delete(item.id) + window.dispatchEvent(new Event('crosspoint-imports-changed')) } } setSending(false) @@ -476,6 +550,7 @@ export default function Send({ tab = '' }) { } } + const busy = sending || files.some(item => item.state === 'sending' || activeImports.has(item.id)) const pending = files.filter((f) => f.state !== 'done').length return ( @@ -491,7 +566,7 @@ export default function Send({ tab = '' }) { ) : ( <> - + [key, importCounts[key || 'books'] ? `${label} (${importCounts[key || 'books']})` : label])} tab={tab} /> {tab === 'wallpaper' ? ( ) : ( @@ -544,8 +619,8 @@ export default function Send({ tab = '' }) { {files.length > 0 && ( - {files.map(({ file, state, error, sentAs, progress, logs }) => ( -
+ {files.map(({ id, file, state, error, sentAs, progress, logs }) => ( +
{(details) => (
@@ -559,10 +634,10 @@ export default function Send({ tab = '' }) { {details}

- {state === 'queued' ? ( - !sending && ( + {state === 'queued' || state === 'error' ? ( + !busy && (