From eda6f8c36e6df63bf04c73e86ca8d565a1eb9dc8 Mon Sep 17 00:00:00 2001 From: jcapayne <7683622+jcapayne@users.noreply.github.com> Date: Thu, 12 Nov 2026 16:37:20 -0500 Subject: [PATCH 1/4] feat: queue shared and opened mobile documents persistently --- app/README.md | 20 +++ .../android/app/src/main/AndroidManifest.xml | 27 ++++ .../crosspointreader/sync/IncomingFiles.kt | 65 +++++++++ .../com/crosspointreader/sync/MainActivity.kt | 37 +++++ .../CrossPointShare.entitlements | 10 ++ .../gen/apple/CrossPointShare/Info.plist | 88 ++++++++++++ .../CrossPointShare/ShareViewController.swift | 132 ++++++++++++++++++ .../project.pbxproj | 126 +++++++++++++++++ .../apple/crosspoint-sync-app_iOS/Info.plist | 74 +++++++++- app/src-tauri/gen/apple/project.yml | 99 ++++++++++++- app/src-tauri/src/books.rs | 4 +- app/src-tauri/src/import_store.rs | 102 ++++++++++++++ app/src-tauri/src/imports.rs | 60 ++++++++ app/src-tauri/src/lib.rs | 16 ++- app/src-tauri/src/widget.rs | 2 +- app/src/App.jsx | 18 ++- app/src/Send.jsx | 84 +++++++++-- app/src/Send.queue.test.jsx | 76 ++++++++++ app/src/importQueue.js | 40 ++++++ app/src/importQueue.test.js | 45 ++++++ docs/mobile-file-imports-testing.md | 60 ++++++++ .../plans/2026-11-12-mobile-file-imports.md | 53 +++++++ .../2026-11-12-mobile-file-imports-design.md | 9 ++ 23 files changed, 1225 insertions(+), 22 deletions(-) create mode 100644 app/src-tauri/gen/android/app/src/main/java/com/crosspointreader/sync/IncomingFiles.kt create mode 100644 app/src-tauri/gen/apple/CrossPointShare/CrossPointShare.entitlements create mode 100644 app/src-tauri/gen/apple/CrossPointShare/Info.plist create mode 100644 app/src-tauri/gen/apple/CrossPointShare/ShareViewController.swift create mode 100644 app/src-tauri/src/import_store.rs create mode 100644 app/src-tauri/src/imports.rs create mode 100644 app/src/Send.queue.test.jsx create mode 100644 app/src/importQueue.js create mode 100644 app/src/importQueue.test.js create mode 100644 docs/mobile-file-imports-testing.md create mode 100644 docs/superpowers/plans/2026-11-12-mobile-file-imports.md create mode 100644 docs/superpowers/specs/2026-11-12-mobile-file-imports-design.md diff --git a/app/README.md b/app/README.md index 819ccdf4..ebdbbbbe 100644 --- a/app/README.md +++ b/app/README.md @@ -116,3 +116,23 @@ 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 `.epub`, `.acsm`, `.md`, or `.txt` files in CrossPoint Sync to +queue them in **Send books**. 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. +The in-app file picker uses the same persistent queue. + +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/src-tauri/gen/android/app/src/main/AndroidManifest.xml b/app/src-tauri/gen/android/app/src/main/AndroidManifest.xml index 8a88b143..5861e04b 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,33 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 00000000..bab74b7c --- /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 EPUB, Markdown, text or ACSM files: $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") +} 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 8b8bcf48..484df933 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 = 0xFFFAFAF9.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 00000000..89e512b7 --- /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 00000000..8f94ae92 --- /dev/null +++ b/app/src-tauri/gen/apple/CrossPointShare/Info.plist @@ -0,0 +1,88 @@ + + + + + CFBundleDisplayName + CrossPoint Sync + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + XPC! + CFBundleShortVersionString + 0.1.2 + CFBundleVersion + 17 + 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").@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 + + + + + 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 00000000..fae71a5d --- /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] + 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 EPUB, Markdown, text or ACSM files." + 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"].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 e0d25cf1..f95d5750 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 dc6f07b4..b71b80b6 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,77 @@ UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight + CFBundleDocumentTypes + + + CFBundleTypeName + Reader documents + CFBundleTypeRole + Viewer + LSHandlerRank + Alternate + LSItemContentTypes + + org.idpf.epub-container + com.adobe.adept.acsm + net.daringfireball.markdown + public.plain-text + + + + 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 + + + - \ 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 faecfd1d..e0066593 100644 --- a/app/src-tauri/gen/apple/project.yml +++ b/app/src-tauri/gen/apple/project.yml @@ -44,6 +44,37 @@ targets: info: path: crosspoint-sync-app_iOS/Info.plist properties: + CFBundleDocumentTypes: + - CFBundleTypeName: Reader documents + CFBundleTypeRole: Viewer + LSHandlerRank: Alternate + LSItemContentTypes: + - org.idpf.epub-container + - com.adobe.adept.acsm + - net.daringfireball.markdown + - public.plain-text + 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 LSRequiresIPhoneOS: true UILaunchStoryboardName: LaunchScreen UIRequiredDeviceCapabilities: [arm64, metal] @@ -81,7 +112,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 +130,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 +189,66 @@ 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: '17' + 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").@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 + 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 4e502726..08a5e722 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/import_store.rs b/app/src-tauri/src/import_store.rs new file mode 100644 index 00000000..cbab072d --- /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"].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.png", "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"] { add(&root, name, b"ok").unwrap(); } + assert_eq!(list(&root).unwrap().len(), 4); + 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 00000000..cf5dd72c --- /dev/null +++ b/app/src-tauri/src/imports.rs @@ -0,0 +1,60 @@ +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()) +} + +// Open In is delivered by tao on both cold and warm launches. Copy while the +// system's security-scoped grant is active, before the URL leaves native code. +#[cfg(target_os = "ios")] +pub fn open(app: &AppHandle, url: &url::Url) { + use objc2_foundation::{NSString, NSURL}; + use tauri::Emitter; + if url.scheme() != "file" { return } + let result = (|| -> Result<(), String> { + let native = NSURL::URLWithString(&NSString::from_str(url.as_str())).ok_or("Invalid file URL")?; + let access = unsafe { native.startAccessingSecurityScopedResource() }; + let result = (|| { + 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")?; + if !import_store::valid_name(name) { return Err("Choose EPUB, Markdown, text or ACSM files".into()); } + let bytes = std::fs::read(&path).map_err(|e| e.to_string())?; + import_store::add(&queue_dir(app)?, name, &bytes).map_err(|e| e.to_string())?; + Ok(()) + })(); + if access { unsafe { native.stopAccessingSecurityScopedResource() }; } + result + })(); + if let Err(error) = result { let _ = app.emit("import-error", error); } +} diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index af18f5e0..116996c6 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -1,4 +1,6 @@ mod books; +mod import_store; +mod imports; mod epub_name; mod mdns; mod opds; @@ -29,6 +31,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, @@ -47,8 +53,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 0b4bdc57..577d9117 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 07e413e0..33f84bf5 100644 --- a/app/src/App.jsx +++ b/app/src/App.jsx @@ -2,10 +2,12 @@ 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 { 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' @@ -358,6 +360,20 @@ function Sidebar({ route, session }) { } function Home({ session, onSession, onLogout, theme }) { + 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(() => { location.hash = '#/send' }, 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?.() } + }, []) useEffect(() => { if (!isApp) return let live = true, stop diff --git a/app/src/Send.jsx b/app/src/Send.jsx index ae2091ba..711c851b 100644 --- a/app/src/Send.jsx +++ b/app/src/Send.jsx @@ -24,6 +24,7 @@ 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, Toggle } from './ui.jsx' import ConversionLog from './ConversionLog.jsx' +import { listImports, addImport, readImport, removeImport } from './importQueue.js' import { transferProgress } from './epub/log.js' const size = (n) => (n < 1024 * 1024 ? `${Math.max(1, Math.round(n / 1024))} KB` : `${(n / 1024 / 1024).toFixed(1)} MB`) @@ -398,6 +399,10 @@ function ReaderFiles({ base, onFoldersChanged }) { ) } +// Transfer ownership survives tab navigation; a remounted screen cannot resend +// an entry whose original screen is still uploading it. +const activeImports = new Set() + export default function Send() { const [prefs, setPrefsState] = useState(loadDevicePrefs) const [device, setDevice] = useState({ state: 'searching', dirs: [] }) @@ -407,6 +412,7 @@ export default function Send() { 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) @@ -427,31 +433,80 @@ export default function Send() { } }, [prefs.host, attempt]) - function add(list) { + useEffect(() => { + if (!isApp) return + let live = true, latest = 0 + const load = async () => { + const request = ++latest + const revision = queueRevision.current + try { + const entries = await listImports() + if (live && request === latest && revision === queueRevision.current) 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) 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) } + }, []) + + 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' }]) + } 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)) + } 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) @@ -467,6 +522,7 @@ export default function Send() { } } + const busy = sending || files.some(item => item.state === 'sending' || activeImports.has(item.id)) const pending = files.filter((f) => f.state !== 'done').length return ( @@ -527,8 +583,8 @@ export default function Send() { {files.length > 0 && ( - {files.map(({ file, state, error, sentAs, progress, logs }) => ( -
+ {files.map(({ id, file, state, error, sentAs, progress, logs }) => ( +
{(details) => (
@@ -542,10 +598,10 @@ export default function Send() { {details}

- {state === 'queued' ? ( - !sending && ( + {state === 'queued' || state === 'error' ? ( + !busy && ( + +
+ ))} + + + ) +} diff --git a/app/src/ImportedAssets.test.jsx b/app/src/ImportedAssets.test.jsx new file mode 100644 index 00000000..7dd2194a --- /dev/null +++ b/app/src/ImportedAssets.test.jsx @@ -0,0 +1,57 @@ +// @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']) +}) diff --git a/app/src/ReaderFonts.jsx b/app/src/ReaderFonts.jsx index db547adf..a94ac06e 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 3797c49c..99aaa5a1 100644 --- a/app/src/Send.jsx +++ b/app/src/Send.jsx @@ -23,6 +23,7 @@ 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' @@ -415,6 +416,7 @@ 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) @@ -447,7 +449,13 @@ export default function Send({ tab = '' }) { const request = ++latest const revision = queueRevision.current try { - const entries = (await listImports()).filter(isBook) + const all = await listImports() + const entries = all.filter(isBook) + if (live && request === latest) setImportCounts(all.reduce((counts, entry) => { + const kind = importKind(entry) + if (kind) counts[kind] = (counts[kind] ?? 0) + 1 + return counts + }, {})) if (live && request === latest && revision === queueRevision.current) setFiles(cur => { if (revision !== queueRevision.current) return cur const byId = new Map(cur.map(item => [item.id, item])) @@ -545,7 +553,7 @@ export default function Send({ tab = '' }) { ) : ( <> - + [key, importCounts[key || 'books'] ? `${label} (${importCounts[key || 'books']})` : label])} tab={tab} /> {tab === 'wallpaper' ? ( ) : ( diff --git a/app/src/Wallpaper.jsx b/app/src/Wallpaper.jsx index 50b591b3..17e9fc11 100644 --- a/app/src/Wallpaper.jsx +++ b/app/src/Wallpaper.jsx @@ -1,5 +1,6 @@ import { useEffect, useRef, useState } from 'react' import { Download, ImagePlus, Loader2, RotateCcw, RotateCw, Search, Send as SendIcon, Sparkles, Tablet, Wand2 } from 'lucide-react' +import ImportedAssets, { runImportedAsset } from './ImportedAssets.jsx' import { isApp } from './api.js' import { reader, sendFile } from './catalogs.js' import { connect, folders, loadDevicePrefs, makeFolder } from './device.js' @@ -228,20 +229,32 @@ export default function Wallpaper() { set(patch) } - function load(src, name, failure) { + const imageRequest = useRef(0) + const pendingImport = useRef(null) + useEffect(() => () => { imageRequest.current += 1 }, []) + + function load(src, name, failure, importId = null, objectURL = false) { + const request = ++imageRequest.current + pendingImport.current = importId const el = new Image() el.crossOrigin = 'anonymous' // community images: keep the canvas readable el.onload = () => { - setImg({ el, name }) + if (objectURL) URL.revokeObjectURL(src) + if (request !== imageRequest.current) return + pendingImport.current = null + setImg({ el, name, importId }) set({ zoom: 1, cx: 0.5, cy: 0.5, rotation: 0 }) setAuto(true) window.scrollTo(0, 0) } - el.onerror = () => setStatus({ error: true, text: failure }) + el.onerror = () => { + if (objectURL) URL.revokeObjectURL(src) + if (request === imageRequest.current) setStatus({ error: true, text: failure }) + } setStatus(null) el.src = src } - const open = (file) => file && load(URL.createObjectURL(file), file.name, "That file isn't an image this device can open.") + const open = (file, entry) => file && load(URL.createObjectURL(file), file.name, "That file isn't an image this device can open.", entry?.id, true) const pick = async (w) => { const failed = "Couldn't download that wallpaper. Check your connection and try again." setStatus({ busy: true, text: 'Loading wallpaper…' }) @@ -308,17 +321,22 @@ export default function Wallpaper() { const prefs = { ...loadDevicePrefs(), optimize: false, renameFromMetadata: false } const file = new File([toBmp(result.current)], fileName(), { type: 'image/bmp' }) try { - const base = await reader(prefs) - let folder = '/.sleep' - try { - await sendFile(file, { ...prefs, folder }) - } catch { - // No /.sleep yet, and the reader won't create dot folders over Wi-Fi: use /sleep, - // which CrossPoint reads whenever /.sleep has no wallpapers. - folder = '/sleep' - if (!(await folders(base)).includes('sleep')) await makeFolder(base, '/', 'sleep') - await sendFile(file, { ...prefs, folder }) + const upload = async () => { + const base = await reader(prefs) + let folder = '/.sleep' + try { + await sendFile(file, { ...prefs, folder }) + } catch { + // No /.sleep yet, and the reader won't create dot folders over Wi-Fi: use /sleep, + // which CrossPoint reads whenever /.sleep has no wallpapers. + folder = '/sleep' + if (!(await folders(base)).includes('sleep')) await makeFolder(base, '/', 'sleep') + await sendFile(file, { ...prefs, folder }) + } + return folder } + const folder = await (img.importId ? runImportedAsset(img.importId, upload) : upload()) + setImg(current => current === img ? { ...current, importId: null } : current) setStatus(null) notify({ title: 'Wallpaper sent to your reader', detail: `${file.name} in ${folder}` }) } catch (e) { @@ -345,6 +363,10 @@ export default function Wallpaper() { return (
+ { + if (pendingImport.current === entry.id) { imageRequest.current += 1; pendingImport.current = null } + setImg(current => current?.importId === entry.id ? { ...current, importId: null } : current) + }} disabled={!!status?.busy} />

Turn a photo into a sleep screen, dithered to your reader's four shades of grey.

diff --git a/app/src/fonts/google.js b/app/src/fonts/google.js index 4d7da087..f38db25b 100644 --- a/app/src/fonts/google.js +++ b/app/src/fonts/google.js @@ -89,14 +89,18 @@ const STYLE = [ ['italic', /[-_ ]?(italic|oblique)$/i], ['regular', /[-_ ]?(regular|normal|book|roman)$/i], ] +export function localFontIdentity(name) { + const stem = name.replace(/\.(ttf|otf)$/i, '') + const [style, re] = STYLE.find(([, re]) => re.test(stem)) ?? ['regular', /$^/] + return { style, family: stem.replace(re, '') } +} export async function localFamily(files) { const styles = {} let family = '' for (const file of files) { - const stem = file.name.replace(/\.(ttf|otf)$/i, '') - const [style, re] = STYLE.find(([, re]) => re.test(stem)) ?? ['regular', /$^/] + const { style, family: name } = localFontIdentity(file.name) styles[style] ??= new Uint8Array(await file.arrayBuffer()) - if (style === 'regular' || !family) family = stem.replace(re, '') + if (style === 'regular' || !family) family = name } styles.regular ??= styles.bold ?? styles.italic ?? styles.bolditalic return { family, styles } diff --git a/app/src/fonts/sendImported.js b/app/src/fonts/sendImported.js new file mode 100644 index 00000000..316f6b05 --- /dev/null +++ b/app/src/fonts/sendImported.js @@ -0,0 +1,39 @@ +import { uploadFont, uploadTrueType } from '../device.js' +import { convert, familyName, localFamily, localFontIdentity, trueTypeFiles } from './google.js' +import { cpfontFamilies } from './saved.js' + +// Shared files use the same font APIs and conversion as the font library, never +// the generic book uploader. Prebuilt files always pass through unchanged. +export function groupFontEntries(selected, entries) { + const identity = entry => /\.cpfont$/i.test(entry.name) + ? { family: entry.name.replace(/(_\d+)?\.cpfont$/i, ''), style: entry.name.toLowerCase(), prebuilt: true } + : { ...localFontIdentity(entry.name), prebuilt: false } + const target = identity(selected), styles = new Set() + return [selected, ...entries.filter(e => e.id !== selected.id)].filter(entry => { + const value = identity(entry) + if (familyName(value.family).toLowerCase() !== familyName(target.family).toLowerCase() || value.prebuilt !== target.prebuilt || styles.has(value.style)) return false + styles.add(value.style) + return true + }) +} +export const sendImportedFont = (file, options) => sendImportedFonts([file], options) +export async function sendImportedFonts(pickedFiles, { base, trueType, progress = () => {} }) { + const file = pickedFiles[0] + if (/\.cpfont$/i.test(file.name)) { + for (const [name, fonts] of Object.entries(await cpfontFamilies(pickedFiles))) { + for (const font of fonts) await uploadFont(base, familyName(name) || 'CustomFont', font) + } + return + } + const picked = await localFamily(pickedFiles) + const family = familyName(picked.family) || 'CustomFont' + if (!picked.styles.regular) throw new Error('Choose .ttf, .otf or .cpfont files') + const actualStyles = new Set(pickedFiles.map(file => localFontIdentity(file.name).style)) + if (!actualStyles.has('regular')) { + if (!trueType) throw new Error('Include the regular font style before converting this family to .cpfont') + delete picked.styles.regular + } + progress(trueType ? 'Sending font…' : 'Converting font…') + const files = trueType ? trueTypeFiles(family, picked.styles) : await convert(family, picked.styles) + for (const font of files) await (trueType ? uploadTrueType : uploadFont)(base, family, font) +} diff --git a/app/src/fonts/sendImported.test.js b/app/src/fonts/sendImported.test.js new file mode 100644 index 00000000..d689e35e --- /dev/null +++ b/app/src/fonts/sendImported.test.js @@ -0,0 +1,69 @@ +// @vitest-environment jsdom +import { File } from 'node:buffer' +import { afterEach, expect, it, vi } from 'vitest' +vi.mock('../api.js',()=>({http:(...args)=>fetch(...args),isApp:false,ipcBytes:vi.fn()})) +import { groupFontEntries, sendImportedFont, sendImportedFonts } from './sendImported.js' +afterEach(()=>vi.unstubAllGlobals()) +it.each(['ttf','otf'])('sends shared %s through the TrueType endpoint with normalized family/style', async ext=>{ + const requests=[] + vi.stubGlobal('fetch',async(url,options)=>{requests.push([url,options]);return {ok:true}}) + await sendImportedFont(new File([ext === 'otf' ? 'OTTOdata' : new Uint8Array([0,1,2])],`Reader-Regular.${ext}`),{base:'http://reader',trueType:true}) + expect(requests.map(([url])=>url)).toEqual(['http://reader/mkdir','http://reader/mkdir','http://reader/upload?path=%2Ffonts%2FReader&overwrite=1']) + expect(requests[2][1].body.get('file').name).toBe(`Reader-Regular.${ext}`) +}) +it('uploads a prebuilt shared font unchanged even with conversion selected', async()=>{ + const requests=[] + vi.stubGlobal('fetch',async(url,options)=>{requests.push([url,options]);return {ok:true}}) + await sendImportedFont(new File(['CPFONTpayload'],'Literata_12.cpfont'),{base:'http://reader',trueType:false}) + expect(requests).toHaveLength(1) + expect(requests[0][0]).toBe('http://reader/api/fonts/upload') + expect(requests[0][1].body.get('family')).toBe('Literata') + expect(requests[0][1].body.get('file').name).toBe('Literata_12.cpfont') +}) +it('rejects a malformed prebuilt font before any reader request',async()=>{ + const request=vi.fn() + vi.stubGlobal('fetch',request) + await expect(sendImportedFont(new File(['bad'],'Bad.cpfont'),{base:'http://reader',trueType:true})).rejects.toThrow("isn't a .cpfont") + expect(request).not.toHaveBeenCalled() +}) +it('does not synthesize a regular face when only a bold face is shared',async()=>{ + const requests=[] + vi.stubGlobal('fetch',async(url,options)=>{requests.push([url,options]);return {ok:true}}) + await sendImportedFont(new File(['bold-data'],'Reader-Bold.ttf'),{base:'http://reader',trueType:true}) + const uploads=requests.filter(([url])=>url.includes('/upload?')) + expect(uploads.map(([,request])=>request.body.get('file').name)).toEqual(['Reader-Bold.ttf']) +}) + +it('groups available faces and sizes without consuming duplicate styles or other families',()=>{ + const entries=[{id:'r',name:'Reader-Regular.ttf'},{id:'b',name:'Reader-Bold.ttf'},{id:'dup',name:'Reader-Regular.ttf'},{id:'other',name:'Other-Regular.ttf'},{id:'cpf',name:'Reader_12.cpfont'}] + expect(groupFontEntries(entries[0],entries).map(e=>e.id)).toEqual(['r','b']) + expect(groupFontEntries(entries[2],entries).map(e=>e.id)).toEqual(['dup','b']) +}) +it('sends regular and bold faces together without replacing regular with bold bytes',async()=>{ + const uploads=[] + vi.stubGlobal('fetch',async(url,options)=>{if(url.includes('/upload?'))uploads.push(options.body.get('file').name);return {ok:true}}) + await sendImportedFonts([new File(['regular'],'Reader-Regular.ttf'),new File(['bold'],'Reader-Bold.ttf')],{base:'http://reader',trueType:true}) + expect(uploads).toEqual(['Reader-Regular.ttf','Reader-Bold.ttf']) +}) +it('retains an incomplete family rather than replacing it with converted bold as regular',async()=>{ + const request=vi.fn() + vi.stubGlobal('fetch',request) + await expect(sendImportedFont(new File(['bold'],'Reader-Bold.ttf'),{base:'http://reader',trueType:false})).rejects.toThrow('Include the regular font style') + expect(request).not.toHaveBeenCalled() +}) +it('converts a complete family together and uploads generated CPFONT files',async()=>{ + const requests=[] + vi.stubGlobal('Worker',class { + terminate() {} + postMessage(message) { + expect(message.family).toBe('Reader') + expect([...message.styles.regular]).toEqual([1,2]) + expect([...message.styles.bold]).toEqual([3,4]) + queueMicrotask(()=>this.onmessage({data:{files:[{name:'Reader_12.cpfont',data:new Uint8Array([67,80,70,79,78,84])}]}})) + } + }) + vi.stubGlobal('fetch',async(url,options)=>{requests.push([url,options]);return {ok:true}}) + await sendImportedFonts([new File([new Uint8Array([1,2])],'Reader-Regular.ttf'),new File([new Uint8Array([3,4])],'Reader-Bold.ttf')],{base:'http://reader',trueType:false}) + expect(requests.map(([url])=>url)).toEqual(['http://reader/api/fonts/upload']) + expect(requests[0][1].body.get('file').name).toBe('Reader_12.cpfont') +}) diff --git a/app/src/importQueue.js b/app/src/importQueue.js index c231126b..65608c60 100644 --- a/app/src/importQueue.js +++ b/app/src/importQueue.js @@ -19,11 +19,11 @@ export function watchImports(onIncoming, onError) { try { const entries = await listImports() if (!live) return - const incoming = entries.some(e => !seen.has(e.id)) - const changed = incoming || entries.length !== seen.size + const incoming = entries.filter(e => !seen.has(e.id)) + const changed = incoming.length > 0 || entries.length !== seen.size seen = new Set(entries.map(e => e.id)) if (changed) window.dispatchEvent(new Event('crosspoint-imports-changed')) - if (incoming) onIncoming() + if (incoming.length) onIncoming(incoming) } catch (error) { if (live) onError(error) } finally { busy = false } } diff --git a/app/src/importTypes.js b/app/src/importTypes.js new file mode 100644 index 00000000..a650d282 --- /dev/null +++ b/app/src/importTypes.js @@ -0,0 +1,14 @@ +export const IMPORT_EXTENSIONS = { + books: ['epub', 'md', 'txt', 'acsm'], + fonts: ['ttf', 'otf', 'cpfont'], + wallpaper: ['png', 'jpg', 'jpeg', 'webp', 'gif', 'bmp'], +} +export function importKind({ name }) { + const ext = name.split('.').pop().toLowerCase() + return Object.keys(IMPORT_EXTENSIONS).find(kind => IMPORT_EXTENSIONS[kind].includes(ext)) ?? null +} +export function importRoute(entries) { + const kinds = new Set(entries.map(importKind).filter(Boolean)) + const kind = kinds.size === 1 ? [...kinds][0] : 'books' + return kind === 'books' ? '#/send' : `#/send/${kind}` +} diff --git a/app/src/importTypes.test.js b/app/src/importTypes.test.js new file mode 100644 index 00000000..2adc957a --- /dev/null +++ b/app/src/importTypes.test.js @@ -0,0 +1,14 @@ +import { expect, it } from 'vitest' +import { importKind, importRoute } from './importTypes.js' +it.each([ + ['Book.EPUB', 'books'], ['Loan.acsm', 'books'], ['Notes.md','books'], ['Notes.txt','books'], + ['Font.ttf','fonts'], ['Font.OTF','fonts'], ['Literata_12.cpfont','fonts'], + ['Sleep.png','wallpaper'], ['Sleep.JPG','wallpaper'], ['Sleep.jpeg','wallpaper'], + ['Sleep.webp','wallpaper'], ['Sleep.gif','wallpaper'], ['Sleep.bmp','wallpaper'], + ['Archive.zip',null], ['Fake.cpfonts',null], ['Font.woff2',null], +])('classifies %s as %s', (name, kind) => expect(importKind({ name })).toBe(kind)) +it('routes a homogeneous batch to its tab and a mixed batch to Send', () => { + expect(importRoute([{name:'A.ttf'},{name:'B.cpfont'}])).toBe('#/send/fonts') + expect(importRoute([{name:'A.png'}])).toBe('#/send/wallpaper') + expect(importRoute([{name:'A.epub'},{name:'A.ttf'}])).toBe('#/send') +}) diff --git a/docs/mobile-file-imports-testing.md b/docs/mobile-file-imports-testing.md index 8be70a66..9effba9b 100644 --- a/docs/mobile-file-imports-testing.md +++ b/docs/mobile-file-imports-testing.md @@ -58,3 +58,28 @@ Tauri's app data directory (`applicationInfo.dataDir`); iOS uses the shared App Group container. No original/source file is deleted. Android delivery receipts live in private preferences so restoring an activity cannot reimport a file already sent or removed. Files and receipts are local to this app installation. + +## Expanded font and image checks + +Share TTF, OTF and CPFONT (singular extension), plus PNG, JPG/JPEG, WebP, GIF +and BMP. Homogeneous batches navigate to Fonts or Sleep screens; mixed batches +open Send with per-tab counts. Repeat in guest mode. + +For fonts, share Regular, Bold and Italic from the same family and send one +row: the available distinct styles send together. Duplicate files of a style +stay independent. Check raw mode uses `/fonts//` uploads, and conversion +uses `/api/fonts/upload`; prebuilt CPFONT bypasses conversion. A standalone raw +Bold must not overwrite Regular. Conversion without a regular style must fail +with an explanation and retain its queued originals. + +For images, select Edit sleep screen, preview/crop/dither, and save locally. +The original must remain queued. A successful send to `/.sleep` (or `/sleep` +fallback) consumes only that original. A failed send retains it. Removing an +image while its preview is open must detach the preview from the removed entry. + +Native Share predicate regression test (run with access to the system UTI registry): + +``` +cd app +swift -module-cache-path /tmp/crosspoint-swift-cache scripts/import-tests/main.swift +``` diff --git a/docs/superpowers/plans/2026-11-13-import-fonts-sleep-screens.md b/docs/superpowers/plans/2026-11-13-import-fonts-sleep-screens.md new file mode 100644 index 00000000..83922c97 --- /dev/null +++ b/docs/superpowers/plans/2026-11-13-import-fonts-sleep-screens.md @@ -0,0 +1,24 @@ +# Font and sleep-screen imports + +Goal: Extend the existing persistent mobile imports to main's font and sleep-screen flows. +Architecture: shared extension classification and tab routing; native producers retain the same atomic queue; category-specific actions reuse font conversion/upload and the image editor. +Spec: docs/superpowers/specs/2026-11-12-mobile-file-imports-design.md + +- [x] Merge upstream main, preserving queue behavior and local Xcode edits. +- [x] Classify supported types and route category batches, including guest mode. +- [x] Add native Android/iOS file registration and queue validation. +- [x] Add shared-asset queue UI; font upload and image editor handoff retain originals until sent/removed. +- [x] Verify category filtering, correct endpoints, failed-send retention, edit retention and successful cleanup. +- [x] Run frontend/Rust tests, production build and available native checks. +- [ ] Review, commit and push as jcapayne. + +Review fixes: explicitly activate CPFONT-only iOS providers (native predicate +RED→GREEN); group font faces and avoid synthesizing Regular for raw Bold/Italic +uploads (regression RED→GREEN); require Regular before whole-family conversion. +Duplicate copies of a style remain queued independently. An image preview is +detached when its original is explicitly removed. + +Verification: 466 frontend tests, 1108 server tests and 22 Rust tests passed +(one pre-existing Rust live-network test ignored). Production frontend build, +iOS Rust check, Share extension build and native activation checks passed. +Android compilation/device testing remains unavailable without an SDK. diff --git a/docs/superpowers/specs/2026-11-12-mobile-file-imports-design.md b/docs/superpowers/specs/2026-11-12-mobile-file-imports-design.md index 2f2efe33..27a2d806 100644 --- a/docs/superpowers/specs/2026-11-12-mobile-file-imports-design.md +++ b/docs/superpowers/specs/2026-11-12-mobile-file-imports-design.md @@ -7,3 +7,15 @@ Use a native filesystem queue shared by Rust and native importers. iOS uses the Approaches considered: document registration alone misses the iOS Share extension; a WebView-only queue cannot receive while closed. Chosen: native receiving plus one durable queue, as authorized in chat. iOS Share confirms storage and asks the user to open the app (no unsupported extension-to-app launch). Open In opens directly. Incoming files never send automatically. Validate extensions and names, confine reads/removals to the queue, avoid overwriting same-name files, leave failed imports unpublished, report errors, and retain originals on transfer failure. Unsupported files and plain browser links are not imported. No unrelated scheme changes are included. + +## Font and sleep-screen extension + +After merging upstream main (98421ff), accept TTF, OTF, CPFONT and PNG, JPG/JPEG, +WebP, GIF, BMP using the same persistent queue. `.cpfonts` is not a format used +by the current code. Homogeneous incoming batches open the corresponding Send +tab; mixed batches open Send with counts on each tab. Guests can import too. +Shared fonts use the existing font APIs and TrueType/conversion setting. Images +open in the existing sleep-screen editor and upload its generated BMP to the +existing sleep folder/fallback. Editing or saving a preview does not consume the +queued original. Remove queued originals only after successful send or explicit +removal. Preserve the local Xcode scheme edit and commit/push as jcapayne. From f7e8f340290f8ceb02c06b20142430fe1390c8d1 Mon Sep 17 00:00:00 2001 From: jcapayne <7683622+jcapayne@users.noreply.github.com> Date: Fri, 13 Nov 2026 19:09:48 -0500 Subject: [PATCH 3/4] fix: coordinate iOS imports and recover queued transfers --- app/src-tauri/Cargo.lock | 1 + app/src-tauri/Cargo.toml | 6 +- app/src-tauri/src/coordinated_import.rs | 109 ++++++++++++++++++++++++ app/src-tauri/src/imports.rs | 38 +++++---- app/src-tauri/src/lib.rs | 2 + app/src/ImportedAssets.jsx | 6 +- app/src/ImportedAssets.test.jsx | 8 ++ app/src/ReaderFonts.jsx | 2 +- app/src/Send.jsx | 23 +++-- app/src/Send.queue.test.jsx | 19 +++++ app/src/Wallpaper.import.test.jsx | 34 ++++++++ app/src/Wallpaper.jsx | 4 +- 12 files changed, 222 insertions(+), 30 deletions(-) create mode 100644 app/src-tauri/src/coordinated_import.rs create mode 100644 app/src/Wallpaper.import.test.jsx diff --git a/app/src-tauri/Cargo.lock b/app/src-tauri/Cargo.lock index 53503558..b8e4a968 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 04e5184d..4ad1204e 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/src/coordinated_import.rs b/app/src-tauri/src/coordinated_import.rs new file mode 100644 index 00000000..11125c20 --- /dev/null +++ b/app/src-tauri/src/coordinated_import.rs @@ -0,0 +1,109 @@ +//! 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; + +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}; + 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_publishes_only_complete_content() { + 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(); + release_tx.send(()).unwrap(); + writer.join().unwrap(); + reader.join().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_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/imports.rs b/app/src-tauri/src/imports.rs index d381ff97..5e85e8c5 100644 --- a/app/src-tauri/src/imports.rs +++ b/app/src-tauri/src/imports.rs @@ -35,26 +35,30 @@ pub async fn remove_import(app: AppHandle, id: String) -> Result<(), String> { import_store::remove(&queue_dir(&app)?, &id).map_err(|e| e.to_string()) } -// Open In is delivered by tao on both cold and warm launches. Copy while the -// system's security-scoped grant is active, before the URL leaves native code. +// 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 objc2_foundation::{NSString, NSURL}; + use crate::coordinated_import::ScopedSource; use tauri::Emitter; if url.scheme() != "file" { return } - let result = (|| -> Result<(), String> { - let native = NSURL::URLWithString(&NSString::from_str(url.as_str())).ok_or("Invalid file URL")?; - let access = unsafe { native.startAccessingSecurityScopedResource() }; - let result = (|| { - 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")?; - if !import_store::valid_name(name) { return Err("Choose books, TTF/OTF/CPFONT fonts, or PNG/JPG/WebP/GIF/BMP sleep screens".into()); } - let bytes = std::fs::read(&path).map_err(|e| e.to_string())?; - import_store::add(&queue_dir(app)?, name, &bytes).map_err(|e| e.to_string())?; - Ok(()) - })(); - if access { unsafe { native.stopAccessingSecurityScopedResource() }; } - result + 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)) })(); - if let Err(error) = result { let _ = app.emit("import-error", error); } + 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 0ad61f3d..90ee25ab 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -1,6 +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; diff --git a/app/src/ImportedAssets.jsx b/app/src/ImportedAssets.jsx index 8144b6d9..7e1e51bb 100644 --- a/app/src/ImportedAssets.jsx +++ b/app/src/ImportedAssets.jsx @@ -20,7 +20,7 @@ export async function runImportedAsset(id, action, removeAfter = true) { } finally { for (const id of ids) active.delete(id); changed() } } -export default function ImportedAssets({ kind, actionLabel, onAction, disabled = false, removeAfter = true, groupEntries, onRemoved }) { +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(() => { @@ -59,8 +59,8 @@ export default function ImportedAssets({ kind, actionLabel, onAction, disabled = {entries.map(entry => (
{entry.name} - - + +
))} diff --git a/app/src/ImportedAssets.test.jsx b/app/src/ImportedAssets.test.jsx index 7dd2194a..873cd50c 100644 --- a/app/src/ImportedAssets.test.jsx +++ b/app/src/ImportedAssets.test.jsx @@ -55,3 +55,11 @@ it('locks and consumes all related font faces only after the grouped upload succ 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 a94ac06e..70265ea9 100644 --- a/app/src/ReaderFonts.jsx +++ b/app/src/ReaderFonts.jsx @@ -160,7 +160,7 @@ export default function ReaderFonts({ base, device }) { return (
- +

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

{ if (!isApp) return - let live = true, latest = 0 + 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) - if (live && request === latest) setImportCounts(all.reduce((counts, entry) => { + setImportCounts(all.reduce((counts, entry) => { const kind = importKind(entry) if (kind) counts[kind] = (counts[kind] ?? 0) + 1 return counts }, {})) - if (live && request === latest && revision === queueRevision.current) setFiles(cur => { + setFiles(cur => { if (revision !== queueRevision.current) return cur const byId = new Map(cur.map(item => [item.id, item])) return entries.map(entry => { @@ -466,12 +474,15 @@ export default function Send({ tab = '' }) { }) }) } catch (error) { - if (live) notify({ error: true, title: "Couldn't load queued files", detail: String(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; window.removeEventListener('crosspoint-imports-changed', load) } + return () => { live = false; clearTimeout(retry); window.removeEventListener('crosspoint-imports-changed', load) } }, []) async function add(list) { @@ -483,6 +494,7 @@ export default function Send({ tab = '' }) { 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) }) } @@ -494,6 +506,7 @@ export default function Send({ tab = '' }) { 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) }) } diff --git a/app/src/Send.queue.test.jsx b/app/src/Send.queue.test.jsx index d69e18b3..f560e685 100644 --- a/app/src/Send.queue.test.jsx +++ b/app/src/Send.queue.test.jsx @@ -74,3 +74,22 @@ it('does not resurrect a removed file when an older list response arrives late', await new Promise(done => setTimeout(done, 20)) expect(screen.queryByText('Shared.epub')).toBeNull() }) +it('recovers from a failed queue refresh without another native change event',async()=>{ + vi.spyOn(queue,'listImports').mockRejectedValueOnce(new Error('Temporary read failure')) + render() + await waitFor(()=>expect(screen.getAllByText('Shared.epub')).toHaveLength(2),{timeout:3000}) +}) +it('reloads a stale snapshot so another source incoming during removal is still shown',async()=>{ + store.entries=store.entries.slice(0,1) + render() + await screen.findByText('Shared.epub') + let resolve + vi.spyOn(queue,'listImports').mockImplementationOnce(()=>new Promise(done=>{resolve=done})) + window.dispatchEvent(new Event('crosspoint-imports-changed')) + store.entries.push({id:'new',name:'Incoming.epub',size:4}) + fireEvent.click(screen.getByRole('button',{name:'Remove Shared.epub'})) + await waitFor(()=>expect(screen.queryByText('Shared.epub')).toBeNull()) + resolve([{id:'new',name:'Incoming.epub',size:4}]) + await screen.findByText('Incoming.epub') + expect(screen.queryByText('Shared.epub')).toBeNull() +}) diff --git a/app/src/Wallpaper.import.test.jsx b/app/src/Wallpaper.import.test.jsx new file mode 100644 index 00000000..ae589bbe --- /dev/null +++ b/app/src/Wallpaper.import.test.jsx @@ -0,0 +1,34 @@ +// @vitest-environment jsdom +import { afterEach, expect, it, vi } from 'vitest' +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +vi.mock('./api.js',()=>({isApp:true})) +vi.mock('./catalogs.js',()=>({reader:async()=> 'http://reader',sendFile:vi.fn(async()=>{})})) +vi.mock('./device.js',async original=>({...await original(),connect:async()=>({status:{}}),folders:async()=>[],loadDevicePrefs:()=>({host:'reader',folder:'/'} )})) +vi.mock('./readers.js',async original=>{const real=await original();return {...real,refreshReaders:async()=>real.readers}}) +vi.mock('./wallpaper/community.js',()=>({CATEGORIES:[],canFilter:()=>false,communityPage:async()=>({items:[],totalPages:0}),fullImage:vi.fn()})) +vi.mock('./wallpaper/render.js',async original=>({...await original(),autoLevels:()=>({}),renderWallpaper:(image,settings)=>({name:image.src,rotation:settings.rotation}),toBmp:result=>new Blob([result?.name??'STALE'])})) +const store=vi.hoisted(()=>({entries:[{id:'old',name:'Old.png',size:1},{id:'new',name:'New.png',size:1}]})) +vi.mock('./importQueue.js',()=>({listImports:async()=>store.entries.map(e=>({...e})),readImport:async entry=>new File(['image'],entry.name),removeImport:async id=>{store.entries=store.entries.filter(e=>e.id!==id)}})) +import Wallpaper from './Wallpaper.jsx' +import {sendFile} from './catalogs.js' +afterEach(()=>{cleanup();vi.unstubAllGlobals();vi.restoreAllMocks()}) +it('sends the selected queued image before its preview animation frame and removes only that image',async()=>{ + vi.stubGlobal('matchMedia',()=>({matches:false,addEventListener(){},removeEventListener(){}})) + vi.stubGlobal('requestAnimationFrame',()=>1) + vi.stubGlobal('cancelAnimationFrame',()=>{}) + vi.stubGlobal('Image',class { + naturalWidth=100;naturalHeight=100 + set src(value){this.value=value;queueMicrotask(()=>this.onload?.())} + get src(){return this.value} + }) + vi.stubGlobal('URL',class extends URL { static createObjectURL(file){return file.name} static revokeObjectURL(){} }) + vi.spyOn(window,'scrollTo').mockImplementation(()=>{}) + render( ) + fireEvent.click((await screen.findAllByRole('button',{name:'Edit sleep screen'}))[1]) + fireEvent.click(await screen.findByRole('button',{name:/Send to reader/})) + await waitFor(()=>expect(store.entries.map(e=>e.id)).toEqual(['old'])) + const file=sendFile.mock.calls[0][0] + const text=await new Promise(resolve=>{const reader=new FileReader();reader.onload=()=>resolve(reader.result);reader.readAsText(file)}) + expect(text).toBe('New.png') + expect(file.type).toBe('image/bmp') +}) diff --git a/app/src/Wallpaper.jsx b/app/src/Wallpaper.jsx index 17e9fc11..7df3ea27 100644 --- a/app/src/Wallpaper.jsx +++ b/app/src/Wallpaper.jsx @@ -319,8 +319,8 @@ export default function Wallpaper() { async function send() { setStatus({ busy: true, text: 'Sending to reader…' }) const prefs = { ...loadDevicePrefs(), optimize: false, renameFromMetadata: false } - const file = new File([toBmp(result.current)], fileName(), { type: 'image/bmp' }) try { + const file = new File([toBmp(renderWallpaper(img.el, s))], fileName(), { type: 'image/bmp' }) const upload = async () => { const base = await reader(prefs) let folder = '/.sleep' @@ -366,7 +366,7 @@ export default function Wallpaper() { { if (pendingImport.current === entry.id) { imageRequest.current += 1; pendingImport.current = null } setImg(current => current?.importId === entry.id ? { ...current, importId: null } : current) - }} disabled={!!status?.busy} /> + }} actionDisabled={!!status?.busy} removalDisabled={!!status?.busy} />

Turn a photo into a sleep screen, dithered to your reader's four shades of grey.

From d3290ce37f4da690b837571739c3406522c1459e Mon Sep 17 00:00:00 2001 From: jcapayne <7683622+jcapayne@users.noreply.github.com> Date: Fri, 13 Nov 2026 19:25:21 -0500 Subject: [PATCH 4/4] fix: serialize iOS import buffering and publication --- app/src-tauri/src/coordinated_import.rs | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/app/src-tauri/src/coordinated_import.rs b/app/src-tauri/src/coordinated_import.rs index 11125c20..fd6849fb 100644 --- a/app/src-tauri/src/coordinated_import.rs +++ b/app/src-tauri/src/coordinated_import.rs @@ -3,6 +3,9 @@ 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, @@ -18,6 +21,10 @@ impl ScopedSource { 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 @@ -55,7 +62,7 @@ mod tests { } #[test] - fn waits_for_a_coordinated_writer_and_publishes_only_complete_content() { + fn waits_for_a_coordinated_writer_and_serializes_other_imports() { autoreleasepool(|_| { let root = root(); let input = root.join("source.epub"); @@ -84,12 +91,27 @@ mod tests { })); 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(); });