Desktop app (#308)
* Implement build profiles and feature policy for offline desktop builds * Introduce unified cross-platform Settings Hub * Refactor main settings into a hierarchical page-based navigation model * Refactor library projection to use shared multiplatform logic * Refactor UI state consumption by removing intermediate screen models * Introduce AndroidSharedStateBridge to centralize state mapping and reduction logic * Refactor state management for tabs, selection, and pinning to use shared bridge logic * Refactor file type management and validation into a centralized shared module * Centralize file type resolution and improve handling of unknown types * Centralize book import logic with SharedImportPlanner * Refactor magnifier geometry logic and coordinate mapping * Properly handle orientation changes in scroll-locked PDF reader * Add screen orientation controls to EPUB and PDF readers * Implement right-to-left (RTL) pagination support and refactor reader menus * Separate right-to-left pagination settings for PDF and EPUB * Ensure PDF page data is scoped by document key for multi tab support * Implement theme-aware link styling for the epub reader * Implement jump history for back and forward navigation in the epub reader * Improve locator handling and navigation logic in paginated reader mode * Implement stable pagination navigation and location tracking * Centralize banner message management and auto-dismiss logic in MainViewModel * Implement zoom and pan state preservation for PDF pan lock mode * Enhance reader navigation UI and workspace layout management in desktop app * Refactor reader navigation sidebar and relocate search controls in desktop app * Enhance reader UI with redesigned selection menus and bottom sheet overlays * Implement custom highlight palettes and reader theme customization in desktop app * Implement cross-platform modal layer and refine reader UI styling * Improve highlight accuracy and implement metadata enrichment on book open in desktop app * Implement two-page spread layout for paginated reader on desktop * Implement persistent caching for book loading and pagination in desktop app * Implement persistent caching for book loading and pagination in desktop app * Optimize reader settings updates by separating layout and appearance changes in desktop app * Improve desktop window branding and native Windows styling * Enhance reader selection interactions and UI across EPUB and PDF viewers in desktop app * Refine selection handle positioning and interaction logic * Implement EPUB selection debug logging and improve handle targeting * Optimize desktop book loading performance and UI responsiveness * Implement anchored zoom gestures and rendering optimizations for the Desktop PDF viewer. * Implement smooth zoom preview for the PDF reader in desktop app * Optimize PDF rendering performance and responsiveness in the desktop reader * Implement conditional diagnostic logging and update desktop build configuration * Implemented hierarchical TOC, custom scrollbars, and improved desktop modal handling * Added management options for annotations and highlights in the sidebar in desktop app * Implemented `SharedStableOutlinedTextField` and updated text input fields to use `TextFieldValue` for improved cursor and selection stability. * Refined library filters and enhanced OPDS functionality in desktop app * Improved EPUB pagination measurement and implemented layout diagnostic logging for desktop app * Added PPTX support including document parsing, rendering, and indexing * Improved PPTX rendering and layout accuracy * Implemented text autofit support for PPTX rendering * Enhanced PPTX rendering with support for custom geometry, automatic numbering, table styles, and image opacity * Improved EPUB pagination accuracy and added layout telemetry in desktop app * Improved folder synchronization with metadata-only mode and hashed sidecar management in desktop app * Implemented rich text font scaling and migrated desktop ink tools to custom pointer input handling * Implemented billing account obfuscation * Implemented hierarchical folder navigation and improved library selection functionality in desktop app * Implemented platform-aware directory resolution and multi-platform native library support for desktop * Added full-screen mode for the reader workspace * Added PDF zoom indicator and interactive vertical scrollbar with page tooltips * Refactored speech bubble prefetching to use a limited radius and improved ML detector initialization and lifecycle management * Updated PDF indexing to replace existing page text and removed search result item keys * Implemented "preparing" foreground notification for TTS service * Optimized PDF rendering performance by pre-calculating page-specific annotations * Refactored desktop packaging tasks and improved distribution configuration * Optimized EPUB parser memory usage and added path traversal protection * Refactored WorkManager monitoring logic and added work pruning * Implemented comprehensive resource cleanup and memory management for WebView-based components to prevent memory leaks * Implemented bitmap size limits and scaling to prevent canvas rendering errors * Split long text paragraphs into multiple semantic blocks during HTML parsing * Implemented local ActionMode for text selection to prevent platform crashes * Refactored PPTX text layout, optimized HtmlParser block detection, and improved banner dismissal logic * Added desktop startup splash screen and deferred WebView initialization * Reorganized settings hub and added separate PDF reader defaults * Implemented embedded cover extraction and metadata support for MOBI and FB2 formats * Implemented batching for MetadataExtractionWorker and optimized EPUB metadata extraction performance. * Implemented procedurally generated book covers and replaced static placeholders * Redesigned search UI with a top bar and results overlay in desktop app * Added PDF page gap and overlay visibility options and implemented DesktopBookImporter * Refactored PDF reader UI with tabbed inspector and improved theme background handling in desktop * Implemented PDF viewport persistence for zoom and scroll positions in desktop app * Improved desktop fullscreen implementation and state restoration * Implemented desktop window state persistence * Implemented flavor-based branding and ProGuard configuration for desktop builds * Implemented precise reader positioning and improved highlight rendering logic in desktop app * Added support for user-editable book metadata * Enhanced book metadata support and integrated info/edit dialogs * Implemented embedded EPUB metadata editing * Improved highlight mapping and added custom scrollbar styling for the reader. * Reduced desktop WebView bundle size by excluding unused locales and runtime files * Added neutral pan mode as the default PDF interaction state. * Refactored library empty states and updated primary navigation tabs in desktop app * Implemented native paginated reader and unified content rendering architecture in desktop epub reader * Implemented native EPUB image rendering for desktop and improved block layout spacing with margin collapsing. * Improved pagination overflow detection in desktop * Implemented multi-block text selection with interactive handles and CFI support in desktop epub pagination
This commit is contained in:
parent
c0d0e57e79
commit
b20ade9946
247 changed files with 43321 additions and 7087 deletions
|
|
@ -28,22 +28,25 @@ internal class DesktopAiByokStore(
|
|||
}
|
||||
|
||||
fun load(): ReaderAiByokSettings {
|
||||
val settingsFileExists = settingsFile.exists()
|
||||
logDesktopTts(
|
||||
"settings_load_start file=\"${settingsFile.absolutePath.desktopTtsPreview(220)}\" " +
|
||||
"exists=${settingsFile.exists()} secureStorage=${secretCodec.isAvailable}"
|
||||
"exists=$settingsFileExists secureStorage=${if (settingsFileExists) "checking" else "skipped"}"
|
||||
)
|
||||
if (!settingsFile.exists()) {
|
||||
if (!settingsFileExists) {
|
||||
logDesktopTts("settings_load_empty reason=file_missing")
|
||||
return ReaderAiByokSettings()
|
||||
}
|
||||
val secureStorageAvailable = secretCodec.isAvailable
|
||||
logDesktopTts("settings_load_secure_storage codec=${secretCodec.name} available=$secureStorageAvailable")
|
||||
val properties = Properties()
|
||||
return runCatching {
|
||||
settingsFile.inputStream().use(properties::load)
|
||||
val legacyGeminiKey = properties.getProperty(LegacyGeminiKey, "")
|
||||
val legacyGroqKey = properties.getProperty(LegacyGroqKey, "")
|
||||
val loadedSettings = ReaderAiByokSettings(
|
||||
geminiKey = loadSecret(properties, GeminiKey, legacyGeminiKey),
|
||||
groqKey = loadSecret(properties, GroqKey, legacyGroqKey),
|
||||
geminiKey = loadSecret(properties, GeminiKey, legacyGeminiKey, secureStorageAvailable),
|
||||
groqKey = loadSecret(properties, GroqKey, legacyGroqKey, secureStorageAvailable),
|
||||
useOneModel = properties.getProperty("useOneModel", "true").toBooleanStrictOrNull() ?: true,
|
||||
modelForAll = properties.getProperty("modelForAll", ""),
|
||||
defineModel = properties.getProperty("defineModel", ""),
|
||||
|
|
@ -58,7 +61,7 @@ internal class DesktopAiByokStore(
|
|||
} else {
|
||||
loadedSettings
|
||||
}
|
||||
if (secretCodec.isAvailable &&
|
||||
if (secureStorageAvailable &&
|
||||
(legacyGeminiKey.isNotBlank() || legacyGroqKey.isNotBlank() || settings != loadedSettings)
|
||||
) {
|
||||
logDesktopTts(
|
||||
|
|
@ -107,7 +110,12 @@ internal class DesktopAiByokStore(
|
|||
)
|
||||
}
|
||||
|
||||
private fun loadSecret(properties: Properties, key: String, legacyPlaintext: String): String {
|
||||
private fun loadSecret(
|
||||
properties: Properties,
|
||||
key: String,
|
||||
legacyPlaintext: String,
|
||||
secureStorageAvailable: Boolean
|
||||
): String {
|
||||
val protectedValue = properties.getProperty(key, "")
|
||||
val decrypted = protectedValue
|
||||
.takeIf { it.isNotBlank() }
|
||||
|
|
@ -118,7 +126,7 @@ internal class DesktopAiByokStore(
|
|||
}
|
||||
.orEmpty()
|
||||
if (decrypted.isNotBlank()) return decrypted
|
||||
return legacyPlaintext.takeIf { secretCodec.isAvailable }.orEmpty()
|
||||
return legacyPlaintext.takeIf { secureStorageAvailable }.orEmpty()
|
||||
}
|
||||
|
||||
private fun Properties.setProtectedSecret(key: String, value: String) {
|
||||
|
|
@ -148,9 +156,7 @@ internal class DesktopAiByokStore(
|
|||
private const val LegacyGroqKey = "groqKey"
|
||||
|
||||
fun defaultSettingsFile(): File {
|
||||
val baseDir = System.getenv("APPDATA")?.takeIf { it.isNotBlank() }
|
||||
?: File(System.getProperty("user.home"), "AppData/Roaming").absolutePath
|
||||
return File(baseDir, "Episteme/ai-byok.properties")
|
||||
return File(desktopUserConfigRoot(), "ai-byok.properties")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,104 @@
|
|||
package com.aryan.reader.desktop
|
||||
|
||||
import com.aryan.reader.shared.ImportedBookFile
|
||||
import com.aryan.reader.shared.ReaderPlatform
|
||||
import com.aryan.reader.shared.SharedFileCapabilities
|
||||
import java.io.File
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.StandardCopyOption
|
||||
import java.security.MessageDigest
|
||||
|
||||
internal data class DesktopPreparedImport(
|
||||
val files: List<ImportedBookFile>,
|
||||
val failedCount: Int
|
||||
)
|
||||
|
||||
internal class DesktopBookImporter(
|
||||
private val booksDirectory: File = File(desktopUserDataRoot(), "books")
|
||||
) {
|
||||
fun prepareImports(files: List<ImportedBookFile>): DesktopPreparedImport {
|
||||
val preparedFiles = mutableListOf<ImportedBookFile>()
|
||||
var failedCount = 0
|
||||
booksDirectory.mkdirs()
|
||||
|
||||
files.forEach { file ->
|
||||
val type = SharedFileCapabilities.fileTypeForName(file.name)
|
||||
if (!SharedFileCapabilities.canOpen(type, ReaderPlatform.DESKTOP)) {
|
||||
preparedFiles += file.copy(sourceFolder = null)
|
||||
return@forEach
|
||||
}
|
||||
|
||||
val source = file.localPath
|
||||
?.let(::File)
|
||||
?.takeIf { it.isFile }
|
||||
|
||||
if (source == null) {
|
||||
failedCount += 1
|
||||
return@forEach
|
||||
}
|
||||
|
||||
val hashResult = runCatching { source.sha256() }
|
||||
if (hashResult.isFailure) {
|
||||
failedCount += 1
|
||||
return@forEach
|
||||
}
|
||||
val hash = hashResult.getOrThrow()
|
||||
val destination = File(booksDirectory, "$hash${file.storageSuffix(source)}")
|
||||
|
||||
val copyResult = runCatching {
|
||||
copyIfNeeded(source, destination)
|
||||
destination
|
||||
}
|
||||
if (copyResult.isFailure) {
|
||||
failedCount += 1
|
||||
return@forEach
|
||||
}
|
||||
val copied = copyResult.getOrThrow()
|
||||
|
||||
preparedFiles += ImportedBookFile(
|
||||
name = file.name,
|
||||
uriString = null,
|
||||
localPath = copied.absolutePath,
|
||||
size = copied.length(),
|
||||
sourceFolder = null,
|
||||
id = hash
|
||||
)
|
||||
}
|
||||
|
||||
return DesktopPreparedImport(
|
||||
files = preparedFiles,
|
||||
failedCount = failedCount
|
||||
)
|
||||
}
|
||||
|
||||
private fun copyIfNeeded(source: File, destination: File) {
|
||||
val sourceFile = source.canonicalFile
|
||||
val destinationFile = destination.canonicalFile
|
||||
if (sourceFile == destinationFile) return
|
||||
destination.parentFile?.mkdirs()
|
||||
Files.copy(source.toPath(), destination.toPath(), StandardCopyOption.REPLACE_EXISTING)
|
||||
}
|
||||
|
||||
private fun ImportedBookFile.storageSuffix(source: File): String {
|
||||
return SharedFileCapabilities.fileExtensionSuffixForName(name)
|
||||
?: source.extension.takeIf { it.isNotBlank() }?.let { ".$it" }
|
||||
?: ".book"
|
||||
}
|
||||
}
|
||||
|
||||
private fun File.sha256(): String {
|
||||
val digest = MessageDigest.getInstance("SHA-256")
|
||||
inputStream().use { input ->
|
||||
val buffer = ByteArray(8 * 1024)
|
||||
while (true) {
|
||||
val read = input.read(buffer)
|
||||
if (read == -1) break
|
||||
digest.update(buffer, 0, read)
|
||||
}
|
||||
}
|
||||
return digest.digest().toHexString()
|
||||
}
|
||||
|
||||
private fun ByteArray.toHexString(): String {
|
||||
return joinToString(separator = "") { byte -> "%02x".format(byte.toInt() and 0xff) }
|
||||
}
|
||||
|
|
@ -0,0 +1,105 @@
|
|||
package com.aryan.reader.desktop
|
||||
|
||||
import com.aryan.reader.shared.ReaderAiByokSettings
|
||||
import com.aryan.reader.shared.SharedFeaturePolicy
|
||||
import java.io.File
|
||||
|
||||
internal const val DesktopFlavorProperty = "episteme.desktop.flavor"
|
||||
internal const val DesktopVersionProperty = "episteme.desktop.version"
|
||||
internal const val DesktopFlavorStandard = "standard"
|
||||
internal const val DesktopFlavorOssOffline = "oss-offline"
|
||||
internal const val EpistemeDesktopStandardAppName = "Episteme"
|
||||
internal const val EpistemeDesktopOssAppName = "Episteme oss"
|
||||
internal const val ComposeApplicationResourcesDirProperty = "compose.application.resources.dir"
|
||||
|
||||
internal data class DesktopBuildProfile(
|
||||
val flavor: String,
|
||||
val appName: String,
|
||||
val buildLabel: String,
|
||||
val featurePolicy: SharedFeaturePolicy
|
||||
) {
|
||||
val isOssOffline: Boolean get() = flavor == DesktopFlavorOssOffline
|
||||
}
|
||||
|
||||
internal fun currentDesktopBuildProfile(): DesktopBuildProfile {
|
||||
return desktopBuildProfileForFlavor(
|
||||
System.getProperty(DesktopFlavorProperty, DesktopFlavorStandard)
|
||||
)
|
||||
}
|
||||
|
||||
internal fun desktopBuildProfileForFlavor(rawFlavor: String?): DesktopBuildProfile {
|
||||
val flavor = normalizedDesktopFlavor(rawFlavor)
|
||||
return when (flavor) {
|
||||
DesktopFlavorOssOffline -> DesktopBuildProfile(
|
||||
flavor = DesktopFlavorOssOffline,
|
||||
appName = EpistemeDesktopOssAppName,
|
||||
buildLabel = "Offline OSS edition",
|
||||
featurePolicy = SharedFeaturePolicy.OssOffline
|
||||
)
|
||||
else -> DesktopBuildProfile(
|
||||
flavor = DesktopFlavorStandard,
|
||||
appName = EpistemeDesktopStandardAppName,
|
||||
buildLabel = "Standard edition",
|
||||
featurePolicy = SharedFeaturePolicy.Standard
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun normalizedDesktopFlavor(rawFlavor: String?): String {
|
||||
return when (rawFlavor?.trim()?.lowercase()) {
|
||||
DesktopFlavorOssOffline,
|
||||
"oss",
|
||||
"episteme-oss" -> DesktopFlavorOssOffline
|
||||
else -> DesktopFlavorStandard
|
||||
}
|
||||
}
|
||||
|
||||
internal fun ReaderAiByokSettings.withDesktopFeaturePolicy(
|
||||
featurePolicy: SharedFeaturePolicy
|
||||
): ReaderAiByokSettings {
|
||||
return if (featurePolicy.aiAndCloud) {
|
||||
sanitized()
|
||||
} else {
|
||||
ReaderAiByokSettings(hideReaderAiFeatures = true)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun bundledDesktopWebViewDir(): File {
|
||||
val platform = currentDesktopPlatform()
|
||||
val resourceDir = System.getProperty(ComposeApplicationResourcesDirProperty)
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?.let(::File)
|
||||
return listOfNotNull(
|
||||
resourceDir?.resolve("kcef-bundle"),
|
||||
File(System.getProperty("user.dir"), "kcef-bundle"),
|
||||
File(System.getProperty("user.dir"), "desktopApp/${platform.kcefBundleDirectoryName}"),
|
||||
File(System.getProperty("user.dir"), "desktopApp/kcef-bundle"),
|
||||
File("desktopApp/${platform.kcefBundleDirectoryName}"),
|
||||
File("desktopApp/kcef-bundle"),
|
||||
File(platform.kcefBundleDirectoryName),
|
||||
File("kcef-bundle")
|
||||
).firstOrNull(::isBundledDesktopWebViewPresent)
|
||||
?: resourceDir?.resolve("kcef-bundle")
|
||||
?: File(platform.kcefBundleDirectoryName)
|
||||
}
|
||||
|
||||
internal fun isBundledDesktopWebViewPresent(
|
||||
dir: File,
|
||||
platform: DesktopPlatform = currentDesktopPlatform()
|
||||
): Boolean {
|
||||
return dir.isDirectory &&
|
||||
bundledDesktopWebViewRequiredPaths(platform).all { requiredPath ->
|
||||
dir.resolve(requiredPath).exists()
|
||||
}
|
||||
}
|
||||
|
||||
internal fun bundledDesktopWebViewRequiredPaths(
|
||||
platform: DesktopPlatform = currentDesktopPlatform()
|
||||
): List<String> {
|
||||
return when (platform.os) {
|
||||
DesktopOperatingSystem.WINDOWS -> listOf("jcef.dll", "libcef.dll")
|
||||
DesktopOperatingSystem.LINUX -> listOf("libcef.so", "chrome-sandbox", "icudtl.dat", "locales")
|
||||
DesktopOperatingSystem.MACOS -> listOf("jcef Helper.app", "Chromium Embedded Framework.framework")
|
||||
DesktopOperatingSystem.OTHER -> emptyList()
|
||||
}
|
||||
}
|
||||
|
|
@ -27,10 +27,11 @@ import java.net.HttpURLConnection
|
|||
import java.net.URL
|
||||
|
||||
class DesktopByokAiAdapter(
|
||||
private val settingsProvider: () -> ReaderAiByokSettings
|
||||
private val settingsProvider: () -> ReaderAiByokSettings,
|
||||
private val networkAccess: () -> Boolean = { true }
|
||||
) : AiAdapter {
|
||||
override val isAvailable: Boolean
|
||||
get() = settingsProvider().sanitized().areReaderAiFeaturesAvailable
|
||||
get() = networkAccess() && settingsProvider().sanitized().areReaderAiFeaturesAvailable
|
||||
|
||||
override suspend fun define(text: String, context: String?): AiDefinitionResult {
|
||||
val result = callTextAi(ReaderAiFeature.DEFINE, text, context)
|
||||
|
|
@ -52,6 +53,7 @@ class DesktopByokAiAdapter(
|
|||
text: String,
|
||||
context: String? = null
|
||||
): Result<String> = withContext(Dispatchers.IO) {
|
||||
if (!networkAccess()) return@withContext Result.failure(IllegalStateException("AI features are unavailable in this desktop build."))
|
||||
if (text.isBlank()) return@withContext Result.failure(IllegalArgumentException("There is no text to send."))
|
||||
when (val requestResult = ReaderByokTextRequests.build(settingsProvider(), feature, text, context)) {
|
||||
ReaderByokTextRequestResult.Hidden -> Result.failure(IllegalStateException("Reader AI features are hidden."))
|
||||
|
|
|
|||
|
|
@ -110,7 +110,7 @@ internal object DesktopComicArchive {
|
|||
.getOrElse { commandError ->
|
||||
error(
|
||||
"Could not open CBR with libarchive. " +
|
||||
"Bundle archive.dll/libarchive for desktop, or keep Windows tar/bsdtar available. " +
|
||||
"Bundle libarchive for this desktop platform, or keep tar/bsdtar available on PATH. " +
|
||||
"Native: ${nativeResult.exceptionOrNull()?.shortMessage().orEmpty()} " +
|
||||
"Command: ${commandError.shortMessage()}"
|
||||
)
|
||||
|
|
@ -558,7 +558,7 @@ private object DesktopLibarchive {
|
|||
.getOrNull()
|
||||
} ?: error(
|
||||
"Native libarchive was not found. Set READER_LIBARCHIVE_PATH/reader.libarchive.path " +
|
||||
"or bundle archive.dll/libarchive for this platform."
|
||||
"or bundle libarchive for this platform."
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -11,7 +11,8 @@ import java.net.URL
|
|||
import java.util.UUID
|
||||
|
||||
class DesktopCustomFontStore(
|
||||
private val fontsDir: File = defaultFontsDir()
|
||||
private val fontsDir: File = defaultFontsDir(),
|
||||
private val googleFontsDownloadAvailable: () -> Boolean = { true }
|
||||
) {
|
||||
private var googleFontsCache: List<String>? = null
|
||||
|
||||
|
|
@ -67,6 +68,9 @@ class DesktopCustomFontStore(
|
|||
}
|
||||
|
||||
fun downloadGoogleFont(fontName: String): Result<CustomFontItem> {
|
||||
if (!googleFontsDownloadAvailable()) {
|
||||
return Result.failure(IllegalStateException("Google Fonts download is unavailable in this desktop build."))
|
||||
}
|
||||
val normalizedFontName = fontName.trim()
|
||||
if (normalizedFontName.isBlank()) {
|
||||
return Result.failure(IllegalArgumentException("Choose a Google Font."))
|
||||
|
|
@ -114,9 +118,7 @@ class DesktopCustomFontStore(
|
|||
"Mozilla/5.0 (Macintosh; U; Intel Mac OS X 10_6_8; en-us) AppleWebKit/533.21.1 (KHTML, like Gecko) Version/5.0.5 Safari/533.21.1"
|
||||
|
||||
fun defaultFontsDir(): File {
|
||||
val baseDir = System.getenv("APPDATA")?.takeIf { it.isNotBlank() }
|
||||
?: File(System.getProperty("user.home"), "AppData/Roaming").absolutePath
|
||||
return File(baseDir, "Episteme/custom_fonts")
|
||||
return File(desktopUserDataRoot(), "custom_fonts")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,16 @@
|
|||
package com.aryan.reader.desktop
|
||||
|
||||
internal const val DesktopDiagnosticsProperty = "episteme.desktop.diagnostics"
|
||||
|
||||
internal val DesktopDiagnosticsEnabled: Boolean =
|
||||
desktopDiagnosticsFlag(System.getProperty(DesktopDiagnosticsProperty))
|
||||
|
||||
internal fun desktopDiagnosticsFlag(rawValue: String?): Boolean {
|
||||
return rawValue?.trim()?.equals("true", ignoreCase = true) == true
|
||||
}
|
||||
|
||||
internal inline fun logDesktopDiagnostic(tag: String, message: () -> String) {
|
||||
if (DesktopDiagnosticsEnabled) {
|
||||
println("$tag ${message()}")
|
||||
}
|
||||
}
|
||||
|
|
@ -69,6 +69,11 @@ object DesktopFolderMetadataExtractor {
|
|||
return enrichBooks(books) { book -> book.id in importedBookIds }
|
||||
}
|
||||
|
||||
fun enrichOpenedBook(book: BookItem): BookItem {
|
||||
if (!book.needsFolderMetadataExtraction()) return book
|
||||
return runCatching { enrichBook(book) }.getOrDefault(book)
|
||||
}
|
||||
|
||||
private fun enrichBooks(
|
||||
books: List<BookItem>,
|
||||
shouldConsider: (BookItem) -> Boolean
|
||||
|
|
@ -107,27 +112,35 @@ object DesktopFolderMetadataExtractor {
|
|||
private fun enrichBook(book: BookItem): BookItem {
|
||||
val file = File(book.path.orEmpty())
|
||||
val size = file.length().takeIf { it > 0L } ?: book.fileSize
|
||||
var title = book.title
|
||||
var author = book.author
|
||||
var extractedTitle: String? = null
|
||||
var extractedAuthor: String? = null
|
||||
var extractedDescription: String? = null
|
||||
var extractedSeriesName: String? = null
|
||||
var extractedSeriesIndex: Double? = null
|
||||
var textMetadataParsed = book.folderTextMetadataParsed
|
||||
var embeddedCover: EmbeddedCover? = null
|
||||
|
||||
when (book.type) {
|
||||
FileType.EPUB -> {
|
||||
val metadata = parseEpubMetadata(file)
|
||||
title = sanitizeTitle(metadata.title) ?: title
|
||||
author = sanitizeAuthor(metadata.author) ?: author
|
||||
extractedTitle = sanitizeTitle(metadata.title)
|
||||
extractedAuthor = sanitizeAuthor(metadata.author)
|
||||
extractedDescription = sanitizeDescription(metadata.description)
|
||||
extractedSeriesName = sanitizeDescription(metadata.seriesName)
|
||||
extractedSeriesIndex = metadata.seriesIndex?.takeIf { it > 0.0 }
|
||||
embeddedCover = metadata.cover
|
||||
textMetadataParsed = true
|
||||
}
|
||||
FileType.PDF -> {
|
||||
val metadata = runCatching { DesktopPdfium.extractMetadata(file) }.getOrNull()
|
||||
title = sanitizeTitle(metadata?.title) ?: title
|
||||
author = sanitizeAuthor(metadata?.author) ?: author
|
||||
extractedTitle = sanitizeTitle(metadata?.title)
|
||||
extractedAuthor = sanitizeAuthor(metadata?.author)
|
||||
extractedDescription = sanitizeDescription(metadata?.description)
|
||||
textMetadataParsed = true
|
||||
}
|
||||
FileType.HTML -> {
|
||||
title = sanitizeTitle(parseHtmlTitle(file)) ?: title
|
||||
extractedTitle = sanitizeTitle(parseHtmlTitle(file))
|
||||
extractedDescription = sanitizeDescription(parseHtmlDescription(file))
|
||||
textMetadataParsed = true
|
||||
}
|
||||
FileType.MOBI,
|
||||
|
|
@ -137,8 +150,8 @@ object DesktopFolderMetadataExtractor {
|
|||
FileType.FODT -> {
|
||||
runCatching { SharedJvmBookLoader.load(file, book.type) }
|
||||
.onSuccess { loaded ->
|
||||
title = sanitizeTitle(loaded.title) ?: title
|
||||
author = sanitizeAuthor(loaded.author) ?: author
|
||||
extractedTitle = sanitizeTitle(loaded.title)
|
||||
extractedAuthor = sanitizeAuthor(loaded.author)
|
||||
textMetadataParsed = true
|
||||
}
|
||||
}
|
||||
|
|
@ -150,10 +163,45 @@ object DesktopFolderMetadataExtractor {
|
|||
?: renderReaderSurfaceCover(book, file)
|
||||
?: saveGeneratedCover(book)
|
||||
|
||||
val nextTitle = if (book.shouldApplyExtractedTitle(file)) {
|
||||
extractedTitle ?: book.title ?: file.nameWithoutExtension
|
||||
} else {
|
||||
book.title
|
||||
}
|
||||
val nextAuthor = if (book.shouldApplyExtractedText(book.author, book.originalAuthor)) {
|
||||
extractedAuthor ?: book.author
|
||||
} else {
|
||||
book.author
|
||||
}
|
||||
val nextDescription = if (book.shouldApplyExtractedText(book.description, book.originalDescription)) {
|
||||
extractedDescription ?: book.description
|
||||
} else {
|
||||
book.description
|
||||
}
|
||||
val nextSeriesName = if (book.shouldApplyExtractedText(book.seriesName, book.originalSeriesName)) {
|
||||
extractedSeriesName ?: book.seriesName
|
||||
} else {
|
||||
book.seriesName
|
||||
}
|
||||
val nextSeriesIndex = if (book.seriesIndex == null || book.seriesIndex == book.originalSeriesIndex) {
|
||||
extractedSeriesIndex ?: book.seriesIndex
|
||||
} else {
|
||||
book.seriesIndex
|
||||
}
|
||||
|
||||
return book.copy(
|
||||
title = title ?: file.nameWithoutExtension,
|
||||
author = author,
|
||||
title = nextTitle,
|
||||
author = nextAuthor,
|
||||
description = nextDescription,
|
||||
seriesName = nextSeriesName,
|
||||
seriesIndex = nextSeriesIndex,
|
||||
originalTitle = book.originalTitle ?: extractedTitle,
|
||||
originalAuthor = book.originalAuthor ?: extractedAuthor,
|
||||
originalSeriesName = book.originalSeriesName ?: extractedSeriesName,
|
||||
originalSeriesIndex = book.originalSeriesIndex ?: extractedSeriesIndex,
|
||||
originalDescription = book.originalDescription ?: extractedDescription,
|
||||
fileSize = size,
|
||||
fileContentModifiedTimestamp = file.lastModified(),
|
||||
coverImagePath = coverPath,
|
||||
folderTextMetadataParsed = textMetadataParsed
|
||||
)
|
||||
|
|
@ -184,6 +232,9 @@ object DesktopFolderMetadataExtractor {
|
|||
return ExtractedBookMetadata(
|
||||
title = opf.tagText("title"),
|
||||
author = opf.tagText("creator"),
|
||||
description = opf.tagInnerContent("description"),
|
||||
seriesName = opf.metaContent("calibre:series"),
|
||||
seriesIndex = opf.metaContent("calibre:series_index")?.toDoubleOrNull(),
|
||||
cover = cover
|
||||
)
|
||||
}
|
||||
|
|
@ -250,6 +301,35 @@ object DesktopFolderMetadataExtractor {
|
|||
}.getOrNull()
|
||||
}
|
||||
|
||||
private fun parseHtmlDescription(file: File): String? {
|
||||
return runCatching {
|
||||
val head = file.inputStream().bufferedReader(Charsets.UTF_8).use { reader ->
|
||||
buildString {
|
||||
var remaining = 64 * 1024
|
||||
val buffer = CharArray(2048)
|
||||
while (remaining > 0) {
|
||||
val read = reader.read(buffer, 0, minOf(buffer.size, remaining))
|
||||
if (read <= 0) break
|
||||
append(buffer, 0, read)
|
||||
remaining -= read
|
||||
if (contains("</head>", ignoreCase = true)) break
|
||||
}
|
||||
}
|
||||
}
|
||||
Regex("""<meta\s+[^>]*>""", RegexOption.IGNORE_CASE)
|
||||
.findAll(head)
|
||||
.firstOrNull { meta ->
|
||||
val name = meta.value.attr("name")
|
||||
val property = meta.value.attr("property")
|
||||
name.equals("description", ignoreCase = true) ||
|
||||
property.equals("og:description", ignoreCase = true)
|
||||
}
|
||||
?.value
|
||||
?.attr("content")
|
||||
?.decodeEntities()
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
private fun saveEmbeddedCover(book: BookItem, cover: EmbeddedCover?): String? {
|
||||
if (cover == null || cover.bytes.isEmpty()) return null
|
||||
val extension = cover.extension.takeIf { it in rasterCoverExtensions } ?: return null
|
||||
|
|
@ -413,9 +493,7 @@ object DesktopFolderMetadataExtractor {
|
|||
if (!overridePath.isNullOrBlank()) {
|
||||
return File(overridePath).apply { mkdirs() }
|
||||
}
|
||||
val root = DesktopLibraryDatabase.defaultDatabaseFile().parentFile
|
||||
?: File(System.getProperty("user.home"), "AppData/Roaming/Episteme")
|
||||
return File(root, "cover_cache").apply { mkdirs() }
|
||||
return File(desktopUserCacheRoot(), "cover_cache").apply { mkdirs() }
|
||||
}
|
||||
|
||||
private fun ZipFile.readTextOrNull(path: String): String? {
|
||||
|
|
@ -451,6 +529,32 @@ object DesktopFolderMetadataExtractor {
|
|||
.orEmpty()
|
||||
}
|
||||
|
||||
private fun String.tagInnerContent(tag: String): String {
|
||||
return Regex(
|
||||
"<(?:[^:>]+:)?$tag\\b[^>]*>(.*?)</(?:[^:>]+:)?$tag>",
|
||||
setOf(RegexOption.IGNORE_CASE, RegexOption.DOT_MATCHES_ALL)
|
||||
)
|
||||
.find(this)
|
||||
?.groupValues
|
||||
?.get(1)
|
||||
?.trim()
|
||||
?.removeSurrounding("<![CDATA[", "]]>")
|
||||
?.decodeEntities()
|
||||
?.trim()
|
||||
.orEmpty()
|
||||
}
|
||||
|
||||
private fun String.metaContent(name: String): String? {
|
||||
return Regex("""<meta\s+[^>]*>""", RegexOption.IGNORE_CASE)
|
||||
.findAll(this)
|
||||
.firstOrNull { it.value.attr("name").equals(name, ignoreCase = true) }
|
||||
?.value
|
||||
?.attr("content")
|
||||
?.decodeEntities()
|
||||
?.trim()
|
||||
?.takeIf { it.isNotBlank() }
|
||||
}
|
||||
|
||||
private fun String.decodeEntities(): String {
|
||||
return replace(" ", " ")
|
||||
.replace("&", "&")
|
||||
|
|
@ -490,6 +594,23 @@ object DesktopFolderMetadataExtractor {
|
|||
?.takeIf { it.isNotBlank() && !it.equals("Unknown", ignoreCase = true) }
|
||||
}
|
||||
|
||||
private fun sanitizeDescription(value: String?): String? {
|
||||
return value
|
||||
?.trim()
|
||||
?.takeIf { it.isNotBlank() && !it.equals("Unknown", ignoreCase = true) }
|
||||
}
|
||||
|
||||
private fun BookItem.shouldApplyExtractedTitle(file: File): Boolean {
|
||||
val current = title?.trim()
|
||||
val fallback = file.nameWithoutExtension
|
||||
return current.isNullOrBlank() || current == fallback || current == originalTitle?.trim()
|
||||
}
|
||||
|
||||
private fun BookItem.shouldApplyExtractedText(current: String?, original: String?): Boolean {
|
||||
val normalized = current?.trim()
|
||||
return normalized.isNullOrBlank() || normalized == original?.trim()
|
||||
}
|
||||
|
||||
private val EpubManifestItem.isRasterCover: Boolean
|
||||
get() = rasterExtension != null
|
||||
|
||||
|
|
@ -513,6 +634,9 @@ object DesktopFolderMetadataExtractor {
|
|||
private data class ExtractedBookMetadata(
|
||||
val title: String? = null,
|
||||
val author: String? = null,
|
||||
val description: String? = null,
|
||||
val seriesName: String? = null,
|
||||
val seriesIndex: Double? = null,
|
||||
val cover: EmbeddedCover? = null
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,19 @@
|
|||
package com.aryan.reader.desktop
|
||||
|
||||
private const val DesktopFolderSyncLogTag = "EpistemeFolderSync"
|
||||
|
||||
internal fun logDesktopFolderSync(message: String) {
|
||||
logDesktopDiagnostic(DesktopFolderSyncLogTag) { message }
|
||||
}
|
||||
|
||||
internal fun Throwable.folderSyncSummary(): String {
|
||||
val type = this::class.java.simpleName.ifBlank { "Throwable" }
|
||||
return "$type: ${message.orEmpty().folderSyncPreview(220)}"
|
||||
}
|
||||
|
||||
internal fun String.folderSyncPreview(maxLength: Int = 160): String {
|
||||
return replace(Regex("\\s+"), " ")
|
||||
.trim()
|
||||
.let { if (it.length <= maxLength) it else it.take(maxLength) + "..." }
|
||||
.replace("\"", "\\\"")
|
||||
}
|
||||
|
|
@ -53,9 +53,15 @@ private data class DesktopTtsSequenceChunk(
|
|||
|
||||
class DesktopGeminiCloudTtsAdapter(
|
||||
private val settingsProvider: () -> ReaderAiByokSettings,
|
||||
private val httpClient: HttpClient = HttpClient.newHttpClient(),
|
||||
private val networkAccess: () -> Boolean = { true },
|
||||
httpClient: HttpClient? = null,
|
||||
private val cacheManager: ReaderTtsFileCacheManager = ReaderTtsFileCacheManager(defaultDesktopTtsCacheRoot())
|
||||
) : TtsAdapter {
|
||||
private val providedHttpClient = httpClient
|
||||
private val httpClient: HttpClient by lazy(LazyThreadSafetyMode.PUBLICATION) {
|
||||
providedHttpClient ?: HttpClient.newHttpClient()
|
||||
}
|
||||
|
||||
@Volatile
|
||||
private var activeLine: SourceDataLine? = null
|
||||
|
||||
|
|
@ -66,7 +72,7 @@ class DesktopGeminiCloudTtsAdapter(
|
|||
private var activePlayer: DesktopStreamingPcmPlayer? = null
|
||||
|
||||
override val isAvailable: Boolean
|
||||
get() = settingsProvider().sanitized().isCloudTtsAvailable
|
||||
get() = networkAccess() && settingsProvider().sanitized().isCloudTtsAvailable
|
||||
|
||||
override suspend fun speak(text: String) {
|
||||
val trimmed = text.trim()
|
||||
|
|
@ -171,6 +177,10 @@ class DesktopGeminiCloudTtsAdapter(
|
|||
"ttsModel=\"${settings.ttsModel.desktopTtsPreview()}\" speaker=\"${settings.ttsSpeakerId.desktopTtsPreview()}\" " +
|
||||
"available=${settings.isCloudTtsAvailable}"
|
||||
)
|
||||
if (!networkAccess()) {
|
||||
logDesktopTts("stream_blocked reason=network_disabled")
|
||||
throw IllegalStateException("Cloud TTS is unavailable in this desktop build.")
|
||||
}
|
||||
if (!settings.isCloudTtsAvailable) {
|
||||
logDesktopTts("stream_blocked reason=not_available")
|
||||
throw IllegalStateException("Cloud TTS needs a saved Gemini key and the Gemini cloud TTS model selected.")
|
||||
|
|
@ -447,9 +457,7 @@ private suspend fun playCachedWav(file: File, player: DesktopStreamingPcmPlayer)
|
|||
}
|
||||
|
||||
private fun defaultDesktopTtsCacheRoot(): File {
|
||||
val baseDir = System.getenv("APPDATA")?.takeIf { it.isNotBlank() }
|
||||
?: File(System.getProperty("user.home"), "AppData/Roaming").absolutePath
|
||||
return File(baseDir, "Episteme/TTS_Cache")
|
||||
return File(desktopUserCacheRoot(), "TTS_Cache")
|
||||
}
|
||||
|
||||
private fun buildGeminiTtsSetup(speakerId: String): String {
|
||||
|
|
|
|||
|
|
@ -19,9 +19,7 @@ class DesktopLibraryDatabase(
|
|||
|
||||
companion object {
|
||||
fun defaultDatabaseFile(): File {
|
||||
val baseDir = System.getenv("APPDATA")?.takeIf { it.isNotBlank() }
|
||||
?: File(System.getProperty("user.home"), "AppData/Roaming").absolutePath
|
||||
return File(baseDir, "Episteme/library.json")
|
||||
return File(desktopUserDataRoot(), "library.json")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import com.aryan.reader.shared.BookItem
|
|||
import com.aryan.reader.shared.BookShelfRef
|
||||
import com.aryan.reader.shared.FileType
|
||||
import com.aryan.reader.shared.LOCAL_FOLDER_ANNOTATION_SUFFIX
|
||||
import com.aryan.reader.shared.LOCAL_FOLDER_SIDECAR_HASH_PREFIX
|
||||
import com.aryan.reader.shared.LOCAL_FOLDER_SYNC_DATA_DIR
|
||||
import com.aryan.reader.shared.LocalFolderSyncEngine
|
||||
import com.aryan.reader.shared.LocalFolderSyncStats
|
||||
|
|
@ -13,6 +14,11 @@ import com.aryan.reader.shared.SharedFolderBookMetadata
|
|||
import com.aryan.reader.shared.SharedFolderScannedFile
|
||||
import com.aryan.reader.shared.SharedReaderScreenState
|
||||
import com.aryan.reader.shared.SyncedFolder
|
||||
import com.aryan.reader.shared.localFolderSyncAnnotationFileName
|
||||
import com.aryan.reader.shared.localFolderSyncAnnotationTempFileName
|
||||
import com.aryan.reader.shared.localFolderSyncMetadataFileName
|
||||
import com.aryan.reader.shared.localFolderSyncMetadataTempFileName
|
||||
import com.aryan.reader.shared.localFolderSyncSidecarStem
|
||||
import com.aryan.reader.shared.pdf.SharedPdfAnnotationSerializer
|
||||
import com.aryan.reader.shared.pdf.SharedPdfAnnotationSidecarCodec
|
||||
import com.aryan.reader.shared.pdf.SharedPdfRichTextLog
|
||||
|
|
@ -24,6 +30,7 @@ import kotlinx.serialization.json.JsonElement
|
|||
import kotlinx.serialization.json.JsonNull
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import kotlinx.serialization.json.longOrNull
|
||||
|
|
@ -60,9 +67,16 @@ object DesktopLocalFolderSync {
|
|||
state: SharedReaderScreenState,
|
||||
shelfRefs: List<BookShelfRef>,
|
||||
targetFolder: File? = null,
|
||||
nowMillis: Long = System.currentTimeMillis()
|
||||
nowMillis: Long = System.currentTimeMillis(),
|
||||
metadataOnly: Boolean = false
|
||||
): DesktopLocalFolderSyncResult {
|
||||
val requestedFolders = foldersToSync(state, targetFolder, nowMillis)
|
||||
val mode = if (metadataOnly) "metadata" else "full"
|
||||
logDesktopFolderSync(
|
||||
"sync.start mode=$mode target=\"${targetFolder?.absolutePath?.folderSyncPreview() ?: "ALL"}\" " +
|
||||
"requestedFolders=${requestedFolders.size} linkedFolders=${state.syncedFolders.size} " +
|
||||
"books=${state.rawLibraryBooks.size}"
|
||||
)
|
||||
var nextState = state
|
||||
var nextShelfRefs = shelfRefs
|
||||
var totalStats = LocalFolderSyncStats()
|
||||
|
|
@ -74,18 +88,36 @@ object DesktopLocalFolderSync {
|
|||
requestedFolders.forEach { folder ->
|
||||
val root = File(folder.uriString)
|
||||
if (!root.isDirectory) {
|
||||
logDesktopFolderSync(
|
||||
"folder.skipMissing mode=$mode name=\"${folder.name.folderSyncPreview()}\" " +
|
||||
"root=\"${root.absolutePath.folderSyncPreview()}\""
|
||||
)
|
||||
failedFolders += folder.name
|
||||
return@forEach
|
||||
}
|
||||
|
||||
val scannedFiles = scanFolder(root = root, sourceFolder = folder.uriString)
|
||||
logDesktopFolderSync(
|
||||
"folder.start mode=$mode name=\"${folder.name.folderSyncPreview()}\" " +
|
||||
"root=\"${root.absolutePath.folderSyncPreview()}\" allowed=${folder.allowedFileTypes.sortedBy { it.name }}"
|
||||
)
|
||||
val scannedFiles = if (metadataOnly) {
|
||||
emptyList()
|
||||
} else {
|
||||
scanFolder(root = root, sourceFolder = folder.uriString)
|
||||
}
|
||||
val remoteMetadata = readAllMetadata(root)
|
||||
logDesktopFolderSync(
|
||||
"folder.inputs mode=$mode name=\"${folder.name.folderSyncPreview()}\" " +
|
||||
"scanned=${scannedFiles.size} supported=${scannedFiles.count { it.type in folder.allowedFileTypes }} " +
|
||||
"remoteMetadata=${remoteMetadata.size}"
|
||||
)
|
||||
val syncResult = LocalFolderSyncEngine.syncFolder(
|
||||
state = nextState,
|
||||
folder = folder,
|
||||
files = scannedFiles,
|
||||
remoteMetadata = remoteMetadata,
|
||||
nowMillis = nowMillis
|
||||
nowMillis = nowMillis,
|
||||
metadataOnly = metadataOnly
|
||||
)
|
||||
nextState = syncResult.state
|
||||
nextShelfRefs = LocalFolderSyncEngine.applyIdMigrationsToShelfRefs(
|
||||
|
|
@ -95,25 +127,46 @@ object DesktopLocalFolderSync {
|
|||
allMigrations += syncResult.idMigrations
|
||||
allRemovedBookIds += syncResult.removedBookIds
|
||||
totalStats += syncResult.stats
|
||||
logDesktopFolderSync(
|
||||
"folder.engine mode=$mode name=\"${folder.name.folderSyncPreview()}\" " +
|
||||
"new=${syncResult.stats.newBooks} updated=${syncResult.stats.updatedBooks} " +
|
||||
"remoteUpdates=${syncResult.stats.remoteMetadataUpdates} removed=${syncResult.stats.removedBooks} " +
|
||||
"migrated=${syncResult.stats.migratedBooks} idMigrations=${syncResult.idMigrations.size}"
|
||||
)
|
||||
|
||||
var syncedBooks = nextState.rawLibraryBooks.filter { it.sourceFolder == folder.uriString }
|
||||
importAnnotationSidecars(root, syncedBooks)
|
||||
val metadataResult = DesktopFolderMetadataExtractor.enrichFolderBooks(
|
||||
books = nextState.rawLibraryBooks,
|
||||
sourceFolder = folder.uriString
|
||||
logDesktopFolderSync(
|
||||
"folder.sidecars.importCheck mode=$mode name=\"${folder.name.folderSyncPreview()}\" books=${syncedBooks.size}"
|
||||
)
|
||||
if (metadataResult.stats.updatedBooks > 0) {
|
||||
nextState = nextState.copy(rawLibraryBooks = metadataResult.books)
|
||||
syncedBooks = nextState.rawLibraryBooks.filter { it.sourceFolder == folder.uriString }
|
||||
importAnnotationSidecars(root, syncedBooks)
|
||||
if (!metadataOnly) {
|
||||
val metadataResult = DesktopFolderMetadataExtractor.enrichFolderBooks(
|
||||
books = nextState.rawLibraryBooks,
|
||||
sourceFolder = folder.uriString
|
||||
)
|
||||
if (metadataResult.stats.updatedBooks > 0) {
|
||||
nextState = nextState.copy(rawLibraryBooks = metadataResult.books)
|
||||
syncedBooks = nextState.rawLibraryBooks.filter { it.sourceFolder == folder.uriString }
|
||||
}
|
||||
totalMetadataStats += metadataResult.stats
|
||||
logDesktopFolderSync(
|
||||
"folder.metadataExtraction name=\"${folder.name.folderSyncPreview()}\" " +
|
||||
"updated=${metadataResult.stats.updatedBooks} covers=${metadataResult.stats.coversUpdated}"
|
||||
)
|
||||
}
|
||||
totalMetadataStats += metadataResult.stats
|
||||
syncedBooks.forEach { book ->
|
||||
saveBookMetadata(book)
|
||||
savePdfAnnotationSidecar(book)
|
||||
if (!metadataOnly) {
|
||||
savePdfAnnotationSidecar(book)
|
||||
}
|
||||
}
|
||||
logDesktopFolderSync(
|
||||
"folder.done mode=$mode name=\"${folder.name.folderSyncPreview()}\" " +
|
||||
"savedCandidates=${syncedBooks.size}"
|
||||
)
|
||||
}
|
||||
|
||||
return DesktopLocalFolderSyncResult(
|
||||
val result = DesktopLocalFolderSyncResult(
|
||||
state = nextState,
|
||||
shelfRefs = nextShelfRefs,
|
||||
stats = totalStats,
|
||||
|
|
@ -122,6 +175,12 @@ object DesktopLocalFolderSync {
|
|||
removedBookIds = allRemovedBookIds,
|
||||
failedFolders = failedFolders
|
||||
)
|
||||
logDesktopFolderSync(
|
||||
"sync.done mode=$mode failed=${failedFolders.size} new=${totalStats.newBooks} " +
|
||||
"updated=${totalStats.updatedBooks} remoteUpdates=${totalStats.remoteMetadataUpdates} " +
|
||||
"removed=${totalStats.removedBooks} metadataExtracted=${totalMetadataStats.updatedBooks}"
|
||||
)
|
||||
return result
|
||||
}
|
||||
|
||||
fun saveBookSidecars(book: BookItem) {
|
||||
|
|
@ -130,18 +189,56 @@ object DesktopLocalFolderSync {
|
|||
}
|
||||
|
||||
fun saveBookMetadata(book: BookItem) {
|
||||
val metadata = book.toSharedFolderBookMetadata() ?: return
|
||||
val root = book.sourceFolder?.let(::File)?.takeIf { it.isDirectory } ?: return
|
||||
val metadata = book.toSharedFolderBookMetadata()
|
||||
if (metadata == null) {
|
||||
logDesktopFolderSync(
|
||||
"metadata.export.skipClean book=${book.id} title=\"${book.title.orEmpty().folderSyncPreview()}\" " +
|
||||
"progress=${book.progressPercentage} recent=${book.isRecent} bookmarks=${book.readerBookmarks.size} " +
|
||||
"highlights=${book.readerHighlights.size}"
|
||||
)
|
||||
return
|
||||
}
|
||||
val root = book.sourceFolder?.let(::File)?.takeIf { it.isDirectory }
|
||||
if (root == null) {
|
||||
logDesktopFolderSync(
|
||||
"metadata.export.skipNoFolder book=${book.id} sourceFolder=\"${book.sourceFolder.orEmpty().folderSyncPreview()}\""
|
||||
)
|
||||
return
|
||||
}
|
||||
logDesktopFolderSync(
|
||||
"metadata.export.request book=${book.id} timestamp=${metadata.lastModifiedTimestamp} " +
|
||||
"progress=${metadata.progressPercentage} recent=${metadata.isRecent} " +
|
||||
"root=\"${root.absolutePath.folderSyncPreview()}\""
|
||||
)
|
||||
saveMetadataToFolder(root, metadata)
|
||||
}
|
||||
|
||||
fun savePdfAnnotationSidecar(book: BookItem) {
|
||||
val path = book.path?.takeIf { it.isNotBlank() } ?: return
|
||||
if (book.type != FileType.PDF) return
|
||||
val root = book.sourceFolder?.let(::File)?.takeIf { it.isDirectory } ?: return
|
||||
val path = book.path?.takeIf { it.isNotBlank() }
|
||||
if (path == null) {
|
||||
logDesktopFolderSync("annotation.export.skipNoPath book=${book.id}")
|
||||
return
|
||||
}
|
||||
if (book.type != FileType.PDF) {
|
||||
logDesktopFolderSync("annotation.export.skipNonPdf book=${book.id} type=${book.type}")
|
||||
return
|
||||
}
|
||||
val root = book.sourceFolder?.let(::File)?.takeIf { it.isDirectory }
|
||||
if (root == null) {
|
||||
logDesktopFolderSync(
|
||||
"annotation.export.skipNoFolder book=${book.id} sourceFolder=\"${book.sourceFolder.orEmpty().folderSyncPreview()}\""
|
||||
)
|
||||
return
|
||||
}
|
||||
val annotationFile = desktopPdfAnnotationFile(path)
|
||||
val bookmarkFile = desktopPdfBookmarkFile(path)
|
||||
val richTextFile = desktopPdfRichTextFile(path)
|
||||
logDesktopFolderSync(
|
||||
"annotation.export.check book=${book.id} root=\"${root.absolutePath.folderSyncPreview()}\" " +
|
||||
"pdfPath=\"${path.folderSyncPreview()}\" hasAnnotations=${annotationFile.isFile} " +
|
||||
"hasBookmarks=${bookmarkFile.isFile} hasText=${richTextFile.isFile} " +
|
||||
"localTs=${maxOf(annotationFile.lastModifiedIfFile(), bookmarkFile.lastModifiedIfFile(), richTextFile.lastModifiedIfFile())}"
|
||||
)
|
||||
val data = buildMap {
|
||||
if (annotationFile.isFile) {
|
||||
val annotationJson = annotationFile.readText().trim()
|
||||
|
|
@ -175,6 +272,9 @@ object DesktopLocalFolderSync {
|
|||
}
|
||||
}
|
||||
if (data.isEmpty()) {
|
||||
logDesktopFolderSync(
|
||||
"annotation.export.skipNoLocalData book=${book.id} pdfPath=\"${path.folderSyncPreview()}\""
|
||||
)
|
||||
SharedPdfRichTextLog.d("desktop.sync.exportSkipNoSidecarData book=${book.id} pdfPath=\"${path.richSyncPreview()}\"")
|
||||
return
|
||||
}
|
||||
|
|
@ -188,6 +288,10 @@ object DesktopLocalFolderSync {
|
|||
JsonElement.serializer(),
|
||||
JsonObject(data)
|
||||
)
|
||||
logDesktopFolderSync(
|
||||
"annotation.export.request book=${book.id} timestamp=$timestamp keys=${data.keys.sorted()} " +
|
||||
"root=\"${root.absolutePath.folderSyncPreview()}\""
|
||||
)
|
||||
if (data.containsKey("text")) {
|
||||
SharedPdfRichTextLog.d(
|
||||
"desktop.sync.exportSidecar book=${book.id} timestamp=$timestamp " +
|
||||
|
|
@ -249,35 +353,64 @@ object DesktopLocalFolderSync {
|
|||
|
||||
private fun readAllMetadata(root: File): Map<String, SharedFolderBookMetadata> {
|
||||
val syncDir = File(root, LOCAL_FOLDER_SYNC_DATA_DIR)
|
||||
if (!syncDir.isDirectory) return emptyMap()
|
||||
return syncDir.listFiles().orEmpty()
|
||||
if (!syncDir.isDirectory) {
|
||||
logDesktopFolderSync("metadata.read.noSyncDir root=\"${root.absolutePath.folderSyncPreview()}\"")
|
||||
return emptyMap()
|
||||
}
|
||||
var candidates = 0
|
||||
var parsed = 0
|
||||
var failed = 0
|
||||
val result = syncDir.listFiles().orEmpty()
|
||||
.asSequence()
|
||||
.filter { it.isFile }
|
||||
.mapNotNull { file -> file.metadataBookIdOrNull()?.let { it to file } }
|
||||
.groupBy({ it.first }, { it.second })
|
||||
.mapNotNull { (bookId, files) ->
|
||||
val best = files
|
||||
.mapNotNull { file ->
|
||||
runCatching { SharedFolderBookMetadata.fromJsonString(file.readText()) }.getOrNull()
|
||||
.filter { it.isFile && it.isMetadataSidecarCandidate() }
|
||||
.mapNotNull { file ->
|
||||
candidates++
|
||||
runCatching { SharedFolderBookMetadata.fromJsonString(file.readText()) }
|
||||
.onSuccess { parsed++ }
|
||||
.onFailure { error ->
|
||||
failed++
|
||||
logDesktopFolderSync(
|
||||
"metadata.read.parseFailed file=\"${file.absolutePath.folderSyncPreview()}\" " +
|
||||
"error=${error.folderSyncSummary()}"
|
||||
)
|
||||
}
|
||||
.filter { it.bookId == bookId }
|
||||
.maxByOrNull { it.lastModifiedTimestamp }
|
||||
best?.let { bookId to it }
|
||||
.getOrNull()
|
||||
}
|
||||
.groupBy { it.bookId }
|
||||
.mapValues { (_, metadata) -> metadata.maxBy { it.lastModifiedTimestamp } }
|
||||
.toMap()
|
||||
logDesktopFolderSync(
|
||||
"metadata.read.done root=\"${root.absolutePath.folderSyncPreview()}\" " +
|
||||
"candidates=$candidates parsed=$parsed failed=$failed winners=${result.size}"
|
||||
)
|
||||
return result
|
||||
}
|
||||
|
||||
private fun saveMetadataToFolder(root: File, metadata: SharedFolderBookMetadata) {
|
||||
val syncDir = File(root, LOCAL_FOLDER_SYNC_DATA_DIR).apply { mkdirs() }
|
||||
val existing = resolveMetadataConflicts(syncDir, metadata.bookId, cleanup = true)
|
||||
if (existing != null && existing.lastModifiedTimestamp > metadata.lastModifiedTimestamp) return
|
||||
if (existing != null && existing.lastModifiedTimestamp > metadata.lastModifiedTimestamp) {
|
||||
logDesktopFolderSync(
|
||||
"metadata.save.skipNewerRemote book=${metadata.bookId} existingTs=${existing.lastModifiedTimestamp} " +
|
||||
"candidateTs=${metadata.lastModifiedTimestamp} root=\"${root.absolutePath.folderSyncPreview()}\""
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
val target = File(syncDir, ".${metadata.bookId}.json")
|
||||
val temp = File(syncDir, ".${metadata.bookId}.tmp")
|
||||
val target = File(syncDir, localFolderSyncMetadataFileName(metadata.bookId))
|
||||
val temp = File(syncDir, uniqueFolderSyncTempName(localFolderSyncMetadataTempFileName(metadata.bookId)))
|
||||
runCatching {
|
||||
temp.writeText(metadata.toJsonString())
|
||||
moveReplacing(temp, target)
|
||||
logDesktopFolderSync(
|
||||
"metadata.save.done book=${metadata.bookId} timestamp=${metadata.lastModifiedTimestamp} " +
|
||||
"target=\"${target.absolutePath.folderSyncPreview()}\" bytes=${target.length()}"
|
||||
)
|
||||
}.onFailure {
|
||||
logDesktopFolderSync(
|
||||
"metadata.save.failed book=${metadata.bookId} timestamp=${metadata.lastModifiedTimestamp} " +
|
||||
"target=\"${target.absolutePath.folderSyncPreview()}\" error=${it.folderSyncSummary()}"
|
||||
)
|
||||
runCatching { temp.delete() }
|
||||
}
|
||||
}
|
||||
|
|
@ -287,18 +420,33 @@ object DesktopLocalFolderSync {
|
|||
bookId: String,
|
||||
cleanup: Boolean
|
||||
): SharedFolderBookMetadata? {
|
||||
val hashedStem = localFolderSyncSidecarStem(bookId)
|
||||
val candidates = syncDir.listFiles().orEmpty().filter { file ->
|
||||
val normalized = file.name.removePrefix(".")
|
||||
file.isFile && (
|
||||
normalized == "$bookId.json" ||
|
||||
normalized.startsWith("$bookId.sync-conflict") ||
|
||||
normalized.startsWith("$bookId.json.sync-conflict")
|
||||
)
|
||||
val normalized = file.normalizedSidecarName()
|
||||
file.isFile &&
|
||||
file.isMetadataSidecarCandidate() &&
|
||||
(
|
||||
normalized.matchesJsonSidecarStem(hashedStem) ||
|
||||
normalized.matchesJsonSidecarStem(bookId)
|
||||
)
|
||||
}
|
||||
if (candidates.isEmpty()) return null
|
||||
if (candidates.size > 1) {
|
||||
logDesktopFolderSync(
|
||||
"metadata.conflicts book=$bookId candidates=${candidates.size} " +
|
||||
"dir=\"${syncDir.absolutePath.folderSyncPreview()}\" cleanup=$cleanup"
|
||||
)
|
||||
}
|
||||
|
||||
val parsed = candidates.mapNotNull { file ->
|
||||
val metadata = runCatching { SharedFolderBookMetadata.fromJsonString(file.readText()) }.getOrNull()
|
||||
val metadata = runCatching { SharedFolderBookMetadata.fromJsonString(file.readText()) }
|
||||
.onFailure { error ->
|
||||
logDesktopFolderSync(
|
||||
"metadata.conflict.parseFailed book=$bookId file=\"${file.absolutePath.folderSyncPreview()}\" " +
|
||||
"error=${error.folderSyncSummary()}"
|
||||
)
|
||||
}
|
||||
.getOrNull()
|
||||
metadata?.takeIf { it.bookId == bookId }?.let { file to it }
|
||||
}
|
||||
val winner = parsed.maxByOrNull { it.second.lastModifiedTimestamp } ?: return null
|
||||
|
|
@ -306,10 +454,21 @@ object DesktopLocalFolderSync {
|
|||
if (cleanup) {
|
||||
candidates
|
||||
.filterNot { it == winner.first }
|
||||
.forEach { runCatching { it.delete() } }
|
||||
val correctName = ".${bookId}.json"
|
||||
.forEach { file ->
|
||||
runCatching { file.delete() }
|
||||
logDesktopFolderSync(
|
||||
"metadata.conflict.deleteLoser book=$bookId file=\"${file.absolutePath.folderSyncPreview()}\""
|
||||
)
|
||||
}
|
||||
val correctName = localFolderSyncMetadataFileName(bookId)
|
||||
if (winner.first.name != correctName) {
|
||||
runCatching { moveReplacing(winner.first, File(syncDir, correctName)) }
|
||||
val target = File(syncDir, correctName)
|
||||
runCatching { moveReplacing(winner.first, target) }
|
||||
.onSuccess {
|
||||
logDesktopFolderSync(
|
||||
"metadata.conflict.renameWinner book=$bookId target=\"${target.absolutePath.folderSyncPreview()}\""
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -318,30 +477,61 @@ object DesktopLocalFolderSync {
|
|||
|
||||
private fun preloadAnnotationSidecars(root: File): Map<String, AnnotationSidecar> {
|
||||
val syncDir = File(root, LOCAL_FOLDER_SYNC_DATA_DIR)
|
||||
if (!syncDir.isDirectory) return emptyMap()
|
||||
return syncDir.listFiles().orEmpty()
|
||||
if (!syncDir.isDirectory) {
|
||||
logDesktopFolderSync("annotation.read.noSyncDir root=\"${root.absolutePath.folderSyncPreview()}\"")
|
||||
return emptyMap()
|
||||
}
|
||||
var candidates = 0
|
||||
var parsed = 0
|
||||
val result = syncDir.listFiles().orEmpty()
|
||||
.asSequence()
|
||||
.filter { it.isFile }
|
||||
.mapNotNull { file -> file.annotationBookIdOrNull()?.let { it to file } }
|
||||
.groupBy({ it.first }, { it.second })
|
||||
.mapNotNull { (bookId, files) ->
|
||||
val best = files
|
||||
.mapNotNull { it.readAnnotationSidecarOrNull() }
|
||||
.maxByOrNull { it.timestamp }
|
||||
best?.let { bookId to it }
|
||||
.filter { it.isFile && it.isAnnotationSidecarCandidate() }
|
||||
.mapNotNull { file ->
|
||||
candidates++
|
||||
file.readAnnotationSidecarOrNull(fallbackBookId = file.legacyAnnotationBookIdOrNull())
|
||||
?.also { parsed++ }
|
||||
}
|
||||
.groupBy { it.bookId }
|
||||
.mapValues { (_, sidecars) -> sidecars.maxBy { it.timestamp } }
|
||||
.toMap()
|
||||
logDesktopFolderSync(
|
||||
"annotation.read.done root=\"${root.absolutePath.folderSyncPreview()}\" " +
|
||||
"candidates=$candidates parsed=$parsed winners=${result.size}"
|
||||
)
|
||||
return result
|
||||
}
|
||||
|
||||
private fun importAnnotationSidecars(root: File, books: List<BookItem>) {
|
||||
if (books.isEmpty()) return
|
||||
if (books.isEmpty()) {
|
||||
logDesktopFolderSync("annotation.import.skipNoBooks root=\"${root.absolutePath.folderSyncPreview()}\"")
|
||||
return
|
||||
}
|
||||
val sidecars = preloadAnnotationSidecars(root)
|
||||
if (sidecars.isEmpty()) return
|
||||
if (sidecars.isEmpty()) {
|
||||
logDesktopFolderSync(
|
||||
"annotation.import.skipNoSidecars root=\"${root.absolutePath.folderSyncPreview()}\" books=${books.size}"
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
books.forEach { book ->
|
||||
val path = book.path?.takeIf { it.isNotBlank() } ?: return@forEach
|
||||
if (book.type != FileType.PDF) return@forEach
|
||||
val sidecar = sidecars[book.id] ?: return@forEach
|
||||
val path = book.path?.takeIf { it.isNotBlank() }
|
||||
if (path == null) {
|
||||
logDesktopFolderSync("annotation.import.skipNoPath book=${book.id}")
|
||||
return@forEach
|
||||
}
|
||||
if (book.type != FileType.PDF) {
|
||||
logDesktopFolderSync("annotation.import.skipNonPdf book=${book.id} type=${book.type}")
|
||||
return@forEach
|
||||
}
|
||||
val sidecar = sidecars[book.id]
|
||||
if (sidecar == null) {
|
||||
logDesktopFolderSync(
|
||||
"annotation.import.skipNoMatchingSidecar book=${book.id} available=${sidecars.keys.size} " +
|
||||
"root=\"${root.absolutePath.folderSyncPreview()}\""
|
||||
)
|
||||
return@forEach
|
||||
}
|
||||
val annotationFile = desktopPdfAnnotationFile(path)
|
||||
val bookmarkFile = desktopPdfBookmarkFile(path)
|
||||
val richTextFile = desktopPdfRichTextFile(path)
|
||||
|
|
@ -350,7 +540,14 @@ object DesktopLocalFolderSync {
|
|||
bookmarkFile.lastModifiedIfFile(),
|
||||
richTextFile.lastModifiedIfFile()
|
||||
)
|
||||
logDesktopFolderSync(
|
||||
"annotation.import.compare book=${book.id} remoteTs=${sidecar.timestamp} localTs=$localTimestamp " +
|
||||
"keys=${sidecar.data.keys.sorted()}"
|
||||
)
|
||||
if (sidecar.timestamp <= localTimestamp + 1000L) {
|
||||
logDesktopFolderSync(
|
||||
"annotation.import.skipOlder book=${book.id} remoteTs=${sidecar.timestamp} localTs=$localTimestamp"
|
||||
)
|
||||
if (sidecar.data.containsKey("text") || richTextFile.isFile) {
|
||||
SharedPdfRichTextLog.d(
|
||||
"desktop.sync.importSkipOlder book=${book.id} sidecarTs=${sidecar.timestamp} " +
|
||||
|
|
@ -365,11 +562,18 @@ object DesktopLocalFolderSync {
|
|||
annotationFile.parentFile?.mkdirs()
|
||||
annotationFile.writeText(SharedPdfAnnotationSerializer.encode(annotations))
|
||||
annotationFile.setLastModified(sidecar.timestamp)
|
||||
logDesktopFolderSync(
|
||||
"annotation.import.writeAnnotations book=${book.id} count=${annotations.size} " +
|
||||
"file=\"${annotationFile.absolutePath.folderSyncPreview()}\""
|
||||
)
|
||||
}
|
||||
sidecar.data["bookmarks"]?.let { bookmarks ->
|
||||
bookmarkFile.parentFile?.mkdirs()
|
||||
bookmarkFile.writeText(desktopFolderSyncJson.encodeToString(JsonElement.serializer(), bookmarks))
|
||||
bookmarkFile.setLastModified(sidecar.timestamp)
|
||||
logDesktopFolderSync(
|
||||
"annotation.import.writeBookmarks book=${book.id} file=\"${bookmarkFile.absolutePath.folderSyncPreview()}\""
|
||||
)
|
||||
}
|
||||
sidecar.data["text"]?.let { richText ->
|
||||
val richDocument = SharedPdfRichTextSerializer.decodeElement(richText)
|
||||
|
|
@ -381,6 +585,10 @@ object DesktopLocalFolderSync {
|
|||
richTextFile.parentFile?.mkdirs()
|
||||
richTextFile.writeText(SharedPdfRichTextSerializer.encode(richDocument))
|
||||
richTextFile.setLastModified(sidecar.timestamp)
|
||||
logDesktopFolderSync(
|
||||
"annotation.import.writeText book=${book.id} textLen=${richDocument.text.length} " +
|
||||
"spans=${richDocument.spans.size} file=\"${richTextFile.absolutePath.folderSyncPreview()}\""
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -392,9 +600,20 @@ object DesktopLocalFolderSync {
|
|||
timestamp: Long
|
||||
) {
|
||||
val syncDir = File(root, LOCAL_FOLDER_SYNC_DATA_DIR).apply { mkdirs() }
|
||||
val data = desktopFolderSyncJson.parseElementOrNull(jsonPayload)?.jsonObjectOrNull() ?: return
|
||||
val data = desktopFolderSyncJson.parseElementOrNull(jsonPayload)?.jsonObjectOrNull()
|
||||
if (data == null) {
|
||||
logDesktopFolderSync(
|
||||
"annotation.save.skipInvalidPayload book=$bookId timestamp=$timestamp " +
|
||||
"root=\"${root.absolutePath.folderSyncPreview()}\" payloadLen=${jsonPayload.length}"
|
||||
)
|
||||
return
|
||||
}
|
||||
val existing = resolveAnnotationConflicts(syncDir, bookId, cleanup = true)
|
||||
if (existing != null && existing.timestamp >= timestamp) {
|
||||
logDesktopFolderSync(
|
||||
"annotation.save.skipNewerExisting book=$bookId existingTs=${existing.timestamp} " +
|
||||
"candidateTs=$timestamp root=\"${root.absolutePath.folderSyncPreview()}\" keys=${data.keys.sorted()}"
|
||||
)
|
||||
if (data.containsKey("text")) {
|
||||
SharedPdfRichTextLog.d(
|
||||
"desktop.sync.saveSidecarSkipExisting book=$bookId existingTs=${existing.timestamp} " +
|
||||
|
|
@ -407,15 +626,20 @@ object DesktopLocalFolderSync {
|
|||
val wrapper = JsonObject(
|
||||
mapOf(
|
||||
"version" to JsonPrimitive(1),
|
||||
"bookId" to JsonPrimitive(bookId),
|
||||
"timestamp" to JsonPrimitive(timestamp),
|
||||
"data" to data
|
||||
)
|
||||
)
|
||||
val target = File(syncDir, ".${bookId}${LOCAL_FOLDER_ANNOTATION_SUFFIX}.json")
|
||||
val temp = File(syncDir, ".${bookId}${LOCAL_FOLDER_ANNOTATION_SUFFIX}.tmp")
|
||||
val target = File(syncDir, localFolderSyncAnnotationFileName(bookId))
|
||||
val temp = File(syncDir, uniqueFolderSyncTempName(localFolderSyncAnnotationTempFileName(bookId)))
|
||||
runCatching {
|
||||
temp.writeText(desktopFolderSyncJson.encodeToString(JsonElement.serializer(), wrapper))
|
||||
moveReplacing(temp, target)
|
||||
logDesktopFolderSync(
|
||||
"annotation.save.done book=$bookId timestamp=$timestamp keys=${data.keys.sorted()} " +
|
||||
"target=\"${target.absolutePath.folderSyncPreview()}\" bytes=${target.length()}"
|
||||
)
|
||||
if (data.containsKey("text")) {
|
||||
SharedPdfRichTextLog.d(
|
||||
"desktop.sync.saveSidecar book=$bookId timestamp=$timestamp " +
|
||||
|
|
@ -423,6 +647,10 @@ object DesktopLocalFolderSync {
|
|||
)
|
||||
}
|
||||
}.onFailure {
|
||||
logDesktopFolderSync(
|
||||
"annotation.save.failed book=$bookId timestamp=$timestamp keys=${data.keys.sorted()} " +
|
||||
"target=\"${target.absolutePath.folderSyncPreview()}\" error=${it.folderSyncSummary()}"
|
||||
)
|
||||
if (data.containsKey("text")) {
|
||||
SharedPdfRichTextLog.d(
|
||||
"desktop.sync.saveSidecarFailed book=$bookId timestamp=$timestamp " +
|
||||
|
|
@ -438,22 +666,40 @@ object DesktopLocalFolderSync {
|
|||
bookId: String,
|
||||
cleanup: Boolean
|
||||
): AnnotationSidecar? {
|
||||
val candidates = syncDir.listFiles().orEmpty().filter { file ->
|
||||
file.isFile && file.annotationBookIdOrNull() == bookId
|
||||
}
|
||||
if (candidates.isEmpty()) return null
|
||||
val parsed = candidates.mapNotNull { file ->
|
||||
file.readAnnotationSidecarOrNull()?.let { file to it }
|
||||
val parsed = syncDir.listFiles().orEmpty()
|
||||
.filter { file -> file.isFile && file.isAnnotationSidecarCandidate() }
|
||||
.mapNotNull { file ->
|
||||
file.readAnnotationSidecarOrNull(fallbackBookId = file.legacyAnnotationBookIdOrNull())
|
||||
?.takeIf { it.bookId == bookId }
|
||||
?.let { file to it }
|
||||
}
|
||||
if (parsed.isEmpty()) return null
|
||||
if (parsed.size > 1) {
|
||||
logDesktopFolderSync(
|
||||
"annotation.conflicts book=$bookId candidates=${parsed.size} " +
|
||||
"dir=\"${syncDir.absolutePath.folderSyncPreview()}\" cleanup=$cleanup"
|
||||
)
|
||||
}
|
||||
val winner = parsed.maxByOrNull { it.second.timestamp } ?: return null
|
||||
|
||||
if (cleanup) {
|
||||
candidates
|
||||
parsed.map { it.first }
|
||||
.filterNot { it == winner.first }
|
||||
.forEach { runCatching { it.delete() } }
|
||||
val correctName = ".${bookId}${LOCAL_FOLDER_ANNOTATION_SUFFIX}.json"
|
||||
.forEach { file ->
|
||||
runCatching { file.delete() }
|
||||
logDesktopFolderSync(
|
||||
"annotation.conflict.deleteLoser book=$bookId file=\"${file.absolutePath.folderSyncPreview()}\""
|
||||
)
|
||||
}
|
||||
val correctName = localFolderSyncAnnotationFileName(bookId)
|
||||
if (winner.first.name != correctName) {
|
||||
runCatching { moveReplacing(winner.first, File(syncDir, correctName)) }
|
||||
val target = File(syncDir, correctName)
|
||||
runCatching { moveReplacing(winner.first, target) }
|
||||
.onSuccess {
|
||||
logDesktopFolderSync(
|
||||
"annotation.conflict.renameWinner book=$bookId target=\"${target.absolutePath.folderSyncPreview()}\""
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -462,6 +708,7 @@ object DesktopLocalFolderSync {
|
|||
}
|
||||
|
||||
private data class AnnotationSidecar(
|
||||
val bookId: String,
|
||||
val timestamp: Long,
|
||||
val data: JsonObject
|
||||
)
|
||||
|
|
@ -485,25 +732,23 @@ private fun File.shouldSyncBookFile(): Boolean {
|
|||
return parentFile?.name != LOCAL_FOLDER_SYNC_DATA_DIR
|
||||
}
|
||||
|
||||
private fun File.metadataBookIdOrNull(): String? {
|
||||
private fun File.isMetadataSidecarCandidate(): Boolean {
|
||||
val fileName = name
|
||||
if (fileName.contains(LOCAL_FOLDER_ANNOTATION_SUFFIX)) return null
|
||||
if (fileName.endsWith(".tmp") || fileName.contains(".syncthing.")) return null
|
||||
if (!fileName.endsWith(".json") && !fileName.contains(".sync-conflict")) return null
|
||||
val normalized = fileName.removePrefix(".")
|
||||
val base = if (normalized.contains(".sync-conflict")) {
|
||||
normalized.substringBefore(".sync-conflict")
|
||||
} else {
|
||||
normalized.substringBeforeLast(".json")
|
||||
}
|
||||
return base.removeSuffix(".json").takeIf { it.isNotBlank() }
|
||||
if (fileName.contains(LOCAL_FOLDER_ANNOTATION_SUFFIX)) return false
|
||||
if (fileName.contains(".tmp") || fileName.contains(".syncthing.")) return false
|
||||
return fileName.endsWith(".json") || fileName.contains(".sync-conflict")
|
||||
}
|
||||
|
||||
private fun File.annotationBookIdOrNull(): String? {
|
||||
private fun File.isAnnotationSidecarCandidate(): Boolean {
|
||||
val fileName = name
|
||||
if (!fileName.contains(LOCAL_FOLDER_ANNOTATION_SUFFIX)) return false
|
||||
if (fileName.contains(".tmp") || fileName.contains(".syncthing.")) return false
|
||||
return fileName.endsWith(".json") || fileName.contains(".sync-conflict")
|
||||
}
|
||||
|
||||
private fun File.legacyAnnotationBookIdOrNull(): String? {
|
||||
var candidate = name
|
||||
if (!candidate.contains(LOCAL_FOLDER_ANNOTATION_SUFFIX)) return null
|
||||
if (!candidate.endsWith(".json") || candidate.endsWith(".tmp")) return null
|
||||
if (candidate.contains(".syncthing.")) return null
|
||||
if (!isAnnotationSidecarCandidate()) return null
|
||||
if (candidate.contains(".sync-conflict")) {
|
||||
candidate = candidate.substringBefore(".sync-conflict")
|
||||
}
|
||||
|
|
@ -511,15 +756,37 @@ private fun File.annotationBookIdOrNull(): String? {
|
|||
if (candidate.endsWith(LOCAL_FOLDER_ANNOTATION_SUFFIX)) {
|
||||
candidate = candidate.substring(0, candidate.length - LOCAL_FOLDER_ANNOTATION_SUFFIX.length)
|
||||
}
|
||||
return candidate.removePrefix(".").takeIf { it.isNotBlank() }
|
||||
val normalized = candidate.removePrefix(".")
|
||||
if (normalized.startsWith(LOCAL_FOLDER_SIDECAR_HASH_PREFIX)) return null
|
||||
return normalized.takeIf { it.isNotBlank() }
|
||||
}
|
||||
|
||||
private fun File.readAnnotationSidecarOrNull(): AnnotationSidecar? {
|
||||
private fun File.normalizedSidecarName(): String {
|
||||
return name.removePrefix(".")
|
||||
}
|
||||
|
||||
private fun String.matchesJsonSidecarStem(stem: String): Boolean {
|
||||
return this == "$stem.json" ||
|
||||
startsWith("$stem.sync-conflict") ||
|
||||
startsWith("$stem.json.sync-conflict")
|
||||
}
|
||||
|
||||
private fun File.readAnnotationSidecarOrNull(fallbackBookId: String? = null): AnnotationSidecar? {
|
||||
return runCatching {
|
||||
val root = desktopFolderSyncJson.parseToJsonElement(readText()).jsonObject
|
||||
val bookId = root["bookId"]
|
||||
?.takeUnless { it is JsonNull }
|
||||
?.jsonPrimitive
|
||||
?.contentOrNull
|
||||
?: fallbackBookId
|
||||
?: error("Missing annotation sidecar bookId")
|
||||
val timestamp = root["timestamp"]?.jsonPrimitive?.longOrNull ?: 0L
|
||||
val data = root["data"]?.jsonObjectOrNull() ?: error("Missing annotation sidecar data")
|
||||
AnnotationSidecar(timestamp = timestamp, data = data)
|
||||
AnnotationSidecar(bookId = bookId, timestamp = timestamp, data = data)
|
||||
}.onFailure { error ->
|
||||
logDesktopFolderSync(
|
||||
"annotation.read.parseFailed file=\"${absolutePath.folderSyncPreview()}\" error=${error.folderSyncSummary()}"
|
||||
)
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
|
|
@ -547,6 +814,12 @@ private fun File.lastModifiedIfFile(): Long {
|
|||
return if (isFile) lastModified() else 0L
|
||||
}
|
||||
|
||||
private fun uniqueFolderSyncTempName(baseName: String): String {
|
||||
val stem = baseName.removeSuffix(".tmp")
|
||||
val nonce = "${System.currentTimeMillis()}_${Thread.currentThread().id}_${System.nanoTime().toString(36)}"
|
||||
return "$stem.$nonce.tmp"
|
||||
}
|
||||
|
||||
private fun String.richSyncPreview(maxLength: Int = 160): String {
|
||||
return replace(Regex("\\s+"), " ")
|
||||
.trim()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,106 @@
|
|||
package com.aryan.reader.desktop
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.toComposeImageBitmap
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import com.aryan.reader.shared.opds.OpdsCatalog
|
||||
import com.aryan.reader.shared.opds.OpdsEntry
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.jetbrains.skia.Image as SkiaImage
|
||||
|
||||
@Composable
|
||||
internal fun DesktopOpdsCoverImage(
|
||||
entry: OpdsEntry,
|
||||
catalog: OpdsCatalog?,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val coverUrl = entry.coverUrl?.takeIf { it.isNotBlank() }
|
||||
val cacheKey = remember(coverUrl, catalog?.id, catalog?.username) {
|
||||
coverUrl?.let { DesktopOpdsCoverImageCache.cacheKey(it, catalog) }
|
||||
}
|
||||
var bitmap by remember(cacheKey) { mutableStateOf(cacheKey?.let { DesktopOpdsCoverImageCache.peek(it) }) }
|
||||
|
||||
LaunchedEffect(cacheKey) {
|
||||
bitmap = if (coverUrl == null || cacheKey == null) {
|
||||
null
|
||||
} else {
|
||||
withContext(Dispatchers.IO) {
|
||||
DesktopOpdsCoverImageCache.load(cacheKey, coverUrl, catalog)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.clip(MaterialTheme.shapes.small)
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
val imageBitmap = bitmap
|
||||
if (imageBitmap != null) {
|
||||
Image(
|
||||
bitmap = imageBitmap,
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.matchParentSize()
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
text = entry.title.take(1).uppercase(),
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private object DesktopOpdsCoverImageCache {
|
||||
private const val MaxEntries = 160
|
||||
|
||||
private val cache = object : LinkedHashMap<String, ImageBitmap>(MaxEntries, 0.75f, true) {
|
||||
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<String, ImageBitmap>?): Boolean {
|
||||
return size > MaxEntries
|
||||
}
|
||||
}
|
||||
|
||||
fun cacheKey(url: String, catalog: OpdsCatalog?): String {
|
||||
return "${catalog?.id.orEmpty()}|${catalog?.username.orEmpty()}|$url"
|
||||
}
|
||||
|
||||
fun peek(cacheKey: String): ImageBitmap? {
|
||||
return synchronized(cache) { cache[cacheKey] }
|
||||
}
|
||||
|
||||
fun load(cacheKey: String, url: String, catalog: OpdsCatalog?): ImageBitmap? {
|
||||
peek(cacheKey)?.let { return it }
|
||||
val bitmap = runCatching {
|
||||
DesktopOpdsHttp.fetchBytes(url, catalog).toImageBitmap()
|
||||
}.getOrNull() ?: return null
|
||||
|
||||
synchronized(cache) {
|
||||
cache[cacheKey] = bitmap
|
||||
}
|
||||
return bitmap
|
||||
}
|
||||
|
||||
private fun ByteArray.toImageBitmap(): ImageBitmap? {
|
||||
return runCatching { SkiaImage.makeFromEncoded(this).toComposeImageBitmap() }.getOrNull()
|
||||
}
|
||||
}
|
||||
|
|
@ -10,14 +10,15 @@ import com.aryan.reader.shared.opds.SharedOpdsParser
|
|||
import com.aryan.reader.shared.opds.SharedOpdsRepository
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.Closeable
|
||||
import java.io.File
|
||||
import java.net.Authenticator
|
||||
import java.net.PasswordAuthentication
|
||||
import java.net.URI
|
||||
import java.net.http.HttpClient
|
||||
import java.net.http.HttpRequest
|
||||
import java.net.http.HttpResponse
|
||||
import java.security.MessageDigest
|
||||
import java.time.Duration
|
||||
import java.util.Base64
|
||||
import java.util.UUID
|
||||
|
||||
internal class DesktopOpdsRepository(
|
||||
|
|
@ -144,47 +145,144 @@ internal data class DesktopOpdsStreamResponse(
|
|||
|
||||
internal object DesktopOpdsHttp {
|
||||
fun fetchString(url: String, username: String?, password: String?): DesktopOpdsTextResponse {
|
||||
val request = request(url).build()
|
||||
val response = client(username, password).send(request, HttpResponse.BodyHandlers.ofString())
|
||||
val response = send(url, username, password, HttpResponse.BodyHandlers.ofString())
|
||||
return DesktopOpdsTextResponse(response.statusCode(), response.body().orEmpty())
|
||||
}
|
||||
|
||||
fun fetchStream(url: String, username: String?, password: String?): DesktopOpdsStreamResponse {
|
||||
val request = request(url).build()
|
||||
val response = client(username, password).send(request, HttpResponse.BodyHandlers.ofInputStream())
|
||||
val response = send(url, username, password, HttpResponse.BodyHandlers.ofInputStream())
|
||||
return DesktopOpdsStreamResponse(response.statusCode(), response.headers(), response.body())
|
||||
}
|
||||
|
||||
fun fetchBytes(url: String, catalog: OpdsCatalog?): ByteArray {
|
||||
val request = request(url).build()
|
||||
val response = client(catalog?.username, catalog?.password).send(request, HttpResponse.BodyHandlers.ofByteArray())
|
||||
val response = send(url, catalog?.username, catalog?.password, HttpResponse.BodyHandlers.ofByteArray())
|
||||
if (response.statusCode() !in 200..299) {
|
||||
error("HTTP ${response.statusCode()}")
|
||||
}
|
||||
return response.body()
|
||||
}
|
||||
|
||||
private fun request(url: String): HttpRequest.Builder {
|
||||
return HttpRequest.newBuilder(URI(url.trim()))
|
||||
private fun <T> send(
|
||||
url: String,
|
||||
username: String?,
|
||||
password: String?,
|
||||
bodyHandler: HttpResponse.BodyHandler<T>
|
||||
): HttpResponse<T> {
|
||||
ensureNetworkAccess()
|
||||
val uri = URI(url.trim())
|
||||
val response = client().send(request(uri).build(), bodyHandler)
|
||||
val challenge = response.headers().firstValue("www-authenticate").orElse(null)
|
||||
val authorization = if (response.statusCode() == 401) {
|
||||
authorizationHeaderForChallenge(
|
||||
challenge = challenge,
|
||||
url = uri.toString(),
|
||||
username = username,
|
||||
password = password
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
if (authorization == null) return response
|
||||
|
||||
(response.body() as? Closeable)?.close()
|
||||
return client().send(
|
||||
request(uri)
|
||||
.header("Authorization", authorization)
|
||||
.build(),
|
||||
bodyHandler
|
||||
)
|
||||
}
|
||||
|
||||
private fun request(uri: URI): HttpRequest.Builder {
|
||||
return HttpRequest.newBuilder(uri)
|
||||
.timeout(Duration.ofSeconds(45))
|
||||
.header("User-Agent", "EpistemeReader/1.0 (Desktop)")
|
||||
}
|
||||
|
||||
private fun client(username: String?, password: String?): HttpClient {
|
||||
val builder = HttpClient.newBuilder()
|
||||
private fun client(): HttpClient {
|
||||
return HttpClient.newBuilder()
|
||||
.connectTimeout(Duration.ofSeconds(20))
|
||||
.followRedirects(HttpClient.Redirect.NORMAL)
|
||||
.build()
|
||||
}
|
||||
|
||||
if (!username.isNullOrBlank() && !password.isNullOrBlank()) {
|
||||
builder.authenticator(
|
||||
object : Authenticator() {
|
||||
override fun getPasswordAuthentication(): PasswordAuthentication {
|
||||
return PasswordAuthentication(username, password.toCharArray())
|
||||
private fun ensureNetworkAccess() {
|
||||
check(currentDesktopBuildProfile().featurePolicy.networkAccess) {
|
||||
"Network access is disabled in this desktop build."
|
||||
}
|
||||
}
|
||||
|
||||
internal fun authorizationHeaderForChallenge(
|
||||
challenge: String?,
|
||||
url: String,
|
||||
username: String?,
|
||||
password: String?,
|
||||
method: String = "GET",
|
||||
cnonce: String = UUID.randomUUID().toString().replace("-", ""),
|
||||
nonceCount: String = "00000001"
|
||||
): String? {
|
||||
if (challenge.isNullOrBlank() || username.isNullOrBlank() || password.isNullOrBlank()) return null
|
||||
return when {
|
||||
challenge.startsWith("Basic", ignoreCase = true) -> {
|
||||
val credentials = "$username:$password".toByteArray(Charsets.ISO_8859_1)
|
||||
"Basic ${Base64.getEncoder().encodeToString(credentials)}"
|
||||
}
|
||||
|
||||
challenge.startsWith("Digest", ignoreCase = true) -> {
|
||||
val params = parseAuthParams(challenge)
|
||||
val realm = params["realm"].orEmpty()
|
||||
val nonce = params["nonce"] ?: return null
|
||||
val qop = params["qop"]
|
||||
?.split(',')
|
||||
?.map { it.trim().trim('"') }
|
||||
?.firstOrNull { it.equals("auth", ignoreCase = true) }
|
||||
val opaque = params["opaque"]
|
||||
val uri = URI(url)
|
||||
val requestUri = buildString {
|
||||
append(uri.rawPath.takeIf { !it.isNullOrBlank() } ?: "/")
|
||||
uri.rawQuery?.let { append('?').append(it) }
|
||||
}
|
||||
val ha1 = md5("$username:$realm:$password")
|
||||
val ha2 = md5("${method.uppercase()}:$requestUri")
|
||||
val responseHash = if (qop != null) {
|
||||
md5("$ha1:$nonce:$nonceCount:$cnonce:$qop:$ha2")
|
||||
} else {
|
||||
md5("$ha1:$nonce:$ha2")
|
||||
}
|
||||
|
||||
buildString {
|
||||
append("Digest username=\"${username.escapeAuthQuote()}\", ")
|
||||
append("realm=\"${realm.escapeAuthQuote()}\", ")
|
||||
append("nonce=\"${nonce.escapeAuthQuote()}\", ")
|
||||
append("uri=\"${requestUri.escapeAuthQuote()}\", ")
|
||||
append("response=\"$responseHash\"")
|
||||
if (qop != null) {
|
||||
append(", qop=$qop, nc=$nonceCount, cnonce=\"${cnonce.escapeAuthQuote()}\"")
|
||||
}
|
||||
if (opaque != null) {
|
||||
append(", opaque=\"${opaque.escapeAuthQuote()}\"")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return builder.build()
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseAuthParams(challenge: String): Map<String, String> {
|
||||
return Regex("""(\w+)=(?:"([^"]*)"|([^,\s]+))""")
|
||||
.findAll(challenge)
|
||||
.associate { match ->
|
||||
match.groupValues[1].lowercase() to (match.groupValues[2].ifBlank { match.groupValues[3] })
|
||||
}
|
||||
}
|
||||
|
||||
private fun md5(input: String): String {
|
||||
val bytes = MessageDigest.getInstance("MD5").digest(input.toByteArray())
|
||||
return bytes.joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
|
||||
private fun String.escapeAuthQuote(): String {
|
||||
return replace("\\", "\\\\").replace("\"", "\\\"")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,30 @@
|
|||
package com.aryan.reader.desktop
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.isSpecified
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.aryan.reader.shared.PdfDisplayMode
|
||||
import com.aryan.reader.shared.ReaderTheme
|
||||
|
||||
internal val DesktopDefaultPdfDisplayMode = PdfDisplayMode.VERTICAL_SCROLL
|
||||
internal val DesktopDefaultPdfVerticalPageGap = 8.dp
|
||||
|
||||
internal fun desktopPdfPageBackgroundColor(
|
||||
theme: ReaderTheme,
|
||||
displayMode: PdfDisplayMode
|
||||
): Color {
|
||||
return when (theme.id) {
|
||||
"no_theme", "system" -> if (displayMode == PdfDisplayMode.VERTICAL_SCROLL) Color.White else Color.Black
|
||||
"reverse" -> if (displayMode == PdfDisplayMode.VERTICAL_SCROLL) Color.Black else Color.White
|
||||
else -> theme.backgroundColor.takeIf { it.isSpecified }
|
||||
?: if (displayMode == PdfDisplayMode.VERTICAL_SCROLL) Color.White else Color.Black
|
||||
}
|
||||
}
|
||||
|
||||
internal fun desktopPdfVerticalViewportBackgroundColor(
|
||||
pageBackgroundColor: Color,
|
||||
gapBackgroundColor: Color,
|
||||
isPageGapVisible: Boolean
|
||||
): Color {
|
||||
return if (isPageGapVisible) gapBackgroundColor else pageBackgroundColor
|
||||
}
|
||||
|
|
@ -1,5 +1,8 @@
|
|||
package com.aryan.reader.desktop
|
||||
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.toComposeImageBitmap
|
||||
import com.aryan.reader.shared.FileType
|
||||
|
|
@ -30,11 +33,18 @@ data class DesktopPdfDocument(
|
|||
val pageSizes: List<DesktopPdfPageSize>,
|
||||
val formatLabel: String = "PDF",
|
||||
val toc: List<PdfTocEntry> = emptyList(),
|
||||
val embeddedAnnotations: List<SharedPdfEmbeddedAnnotation> = emptyList()
|
||||
private val initialEmbeddedAnnotations: List<SharedPdfEmbeddedAnnotation> = emptyList()
|
||||
) {
|
||||
var embeddedAnnotations: List<SharedPdfEmbeddedAnnotation> by mutableStateOf(initialEmbeddedAnnotations)
|
||||
private set
|
||||
|
||||
private val textPageCache = LinkedHashMap<Int, DesktopPdfTextPageData>()
|
||||
private val searchIndex = SharedPdfSearchIndex(pageCount)
|
||||
|
||||
fun replaceEmbeddedAnnotations(annotations: List<SharedPdfEmbeddedAnnotation>) {
|
||||
embeddedAnnotations = annotations
|
||||
}
|
||||
|
||||
fun textPageData(pageIndex: Int): DesktopPdfTextPageData {
|
||||
if (pageIndex !in 0 until pageCount) return DesktopPdfTextPageData()
|
||||
val cached = synchronized(textPageCache) { textPageCache[pageIndex] }
|
||||
|
|
@ -93,7 +103,13 @@ data class DesktopPdfPageRender(
|
|||
|
||||
data class DesktopPdfMetadata(
|
||||
val title: String? = null,
|
||||
val author: String? = null
|
||||
val author: String? = null,
|
||||
val description: String? = null
|
||||
)
|
||||
|
||||
internal val DesktopPdfZoomSpec = PdfZoomSpec(
|
||||
max = 8.0f,
|
||||
maxRenderPixels = 64_000_000
|
||||
)
|
||||
|
||||
data class DesktopPdfTextChar(
|
||||
|
|
@ -133,10 +149,10 @@ object DesktopPdfium {
|
|||
|
||||
private val textUrlRegex = Regex("""\b(?:https?://|www\.)[^\s<>"']+""", RegexOption.IGNORE_CASE)
|
||||
private val pdfiumDll: File by lazy(::resolvePdfiumDll)
|
||||
private val zoomSpec = PdfZoomSpec()
|
||||
private val zoomSpec = DesktopPdfZoomSpec
|
||||
private val api: PdfiumLibrary by lazy {
|
||||
require(pdfiumDll.exists()) {
|
||||
"Missing Pdfium DLL. Expected pdfium-v8-win-x64 under third_party/pdfium/win-x64-v8/bin/pdfium.dll."
|
||||
missingPdfiumLibraryMessage(pdfiumDll)
|
||||
}
|
||||
Native.load(pdfiumDll.absolutePath, PdfiumLibrary::class.java)
|
||||
}
|
||||
|
|
@ -205,7 +221,7 @@ object DesktopPdfium {
|
|||
}
|
||||
|
||||
@Synchronized
|
||||
fun load(file: File, password: String? = null): DesktopPdfDocument {
|
||||
fun load(file: File, password: String? = null, loadEmbeddedAnnotations: Boolean = true): DesktopPdfDocument {
|
||||
initLibrary()
|
||||
val startedAt = System.currentTimeMillis()
|
||||
val loadedDocument = loadDocument(file, password)
|
||||
|
|
@ -217,12 +233,13 @@ object DesktopPdfium {
|
|||
val pageCount = api.FPDF_GetPageCount(document)
|
||||
logPdfiumOpen("metadata_loaded pageCount=$pageCount elapsedMs=${System.currentTimeMillis() - startedAt}")
|
||||
val pageSizes = (0 until pageCount).map { pageIndex ->
|
||||
loadPage(document, pageIndex).usePointer { page ->
|
||||
DesktopPdfPageSize(
|
||||
width = api.FPDF_GetPageWidthF(page),
|
||||
height = api.FPDF_GetPageHeightF(page)
|
||||
)
|
||||
}
|
||||
pageSizeByIndex(document, pageIndex)
|
||||
?: loadPage(document, pageIndex).usePointer { page ->
|
||||
DesktopPdfPageSize(
|
||||
width = api.FPDF_GetPageWidthF(page),
|
||||
height = api.FPDF_GetPageHeightF(page)
|
||||
)
|
||||
}
|
||||
}
|
||||
logPdfiumOpen("page_sizes_loaded pages=$pageCount elapsedMs=${System.currentTimeMillis() - startedAt}")
|
||||
|
||||
|
|
@ -230,11 +247,17 @@ object DesktopPdfium {
|
|||
logPdfiumOpen("text_index_deferred pages=$pageCount elapsedMs=${System.currentTimeMillis() - startedAt}")
|
||||
val toc = extractTableOfContents(document, pageCount)
|
||||
logPdfiumOpen("toc_extracted entries=${toc.size} elapsedMs=${System.currentTimeMillis() - startedAt}")
|
||||
val embeddedAnnotations = extractEmbeddedAnnotations(document, pageSizes)
|
||||
logPdfiumOpen(
|
||||
"embedded_annotations_extracted count=${embeddedAnnotations.size} " +
|
||||
"elapsedMs=${System.currentTimeMillis() - startedAt}"
|
||||
)
|
||||
val embeddedAnnotations = if (loadEmbeddedAnnotations) {
|
||||
extractEmbeddedAnnotations(document, pageSizes).also { annotations ->
|
||||
logPdfiumOpen(
|
||||
"embedded_annotations_extracted count=${annotations.size} " +
|
||||
"elapsedMs=${System.currentTimeMillis() - startedAt}"
|
||||
)
|
||||
}
|
||||
} else {
|
||||
logPdfiumOpen("embedded_annotations_deferred elapsedMs=${System.currentTimeMillis() - startedAt}")
|
||||
emptyList()
|
||||
}
|
||||
|
||||
val result = DesktopPdfDocument(
|
||||
path = file.absolutePath,
|
||||
|
|
@ -242,7 +265,7 @@ object DesktopPdfium {
|
|||
pageCount = pageCount,
|
||||
pageSizes = pageSizes,
|
||||
toc = toc,
|
||||
embeddedAnnotations = embeddedAnnotations
|
||||
initialEmbeddedAnnotations = embeddedAnnotations
|
||||
)
|
||||
logPdfiumOpen("open_complete elapsedMs=${System.currentTimeMillis() - startedAt}")
|
||||
return result
|
||||
|
|
@ -296,6 +319,25 @@ object DesktopPdfium {
|
|||
)
|
||||
}
|
||||
|
||||
fun loadEmbeddedAnnotations(document: DesktopPdfDocument): List<SharedPdfEmbeddedAnnotation> {
|
||||
if (synchronized(this) { openComicDocuments.containsKey(document.path) }) return emptyList()
|
||||
val startedAt = System.currentTimeMillis()
|
||||
val annotations = mutableListOf<SharedPdfEmbeddedAnnotation>()
|
||||
for ((pageIndex, pageSize) in document.pageSizes.withIndex()) {
|
||||
val pageAnnotations = synchronized(this) {
|
||||
if (openComicDocuments.containsKey(document.path)) return annotations
|
||||
val nativeDocument = openDocuments[document.path]?.pointer ?: return annotations
|
||||
extractEmbeddedAnnotationsForPage(nativeDocument, pageIndex, pageSize)
|
||||
}
|
||||
annotations += pageAnnotations
|
||||
}
|
||||
logPdfiumOpen(
|
||||
"embedded_annotations_loaded_async count=${annotations.size} " +
|
||||
"elapsedMs=${System.currentTimeMillis() - startedAt}"
|
||||
)
|
||||
return annotations
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun extractMetadata(file: File, password: String? = null): DesktopPdfMetadata {
|
||||
initLibrary()
|
||||
|
|
@ -415,8 +457,10 @@ object DesktopPdfium {
|
|||
scale: Float,
|
||||
renderAnnotations: Boolean = true
|
||||
): DesktopPdfPageRender {
|
||||
val pageSize = document.pageSizes.getOrNull(pageIndex) ?: error("Invalid PDF page index $pageIndex.")
|
||||
val safeScale = zoomSpec.safeRenderScale(pageSize.width, pageSize.height, scale)
|
||||
openComicDocuments[document.path]?.let { comic ->
|
||||
val image = comic.renderPageBufferedImage(pageIndex, scale)
|
||||
val image = comic.renderPageBufferedImage(pageIndex, safeScale)
|
||||
return DesktopPdfPageRender(
|
||||
image = image.toComposeImageBitmap(),
|
||||
width = image.width,
|
||||
|
|
@ -424,8 +468,6 @@ object DesktopPdfium {
|
|||
)
|
||||
}
|
||||
val nativeDocument = openDocuments[document.path]?.pointer ?: error("PDF document is not open.")
|
||||
val pageSize = document.pageSizes.getOrNull(pageIndex) ?: error("Invalid PDF page index $pageIndex.")
|
||||
val safeScale = zoomSpec.safeRenderScale(pageSize.width, pageSize.height, scale)
|
||||
val width = (pageSize.width * safeScale).roundToInt().coerceAtLeast(1)
|
||||
val height = (pageSize.height * safeScale).roundToInt().coerceAtLeast(1)
|
||||
val stride = width * 4
|
||||
|
|
@ -459,12 +501,12 @@ object DesktopPdfium {
|
|||
scale: Float,
|
||||
renderAnnotations: Boolean = true
|
||||
): BufferedImage {
|
||||
openComicDocuments[document.path]?.let { comic ->
|
||||
return comic.renderPageBufferedImage(pageIndex, scale)
|
||||
}
|
||||
val nativeDocument = openDocuments[document.path]?.pointer ?: error("PDF document is not open.")
|
||||
val pageSize = document.pageSizes.getOrNull(pageIndex) ?: error("Invalid PDF page index $pageIndex.")
|
||||
val safeScale = zoomSpec.safeRenderScale(pageSize.width, pageSize.height, scale)
|
||||
openComicDocuments[document.path]?.let { comic ->
|
||||
return comic.renderPageBufferedImage(pageIndex, safeScale)
|
||||
}
|
||||
val nativeDocument = openDocuments[document.path]?.pointer ?: error("PDF document is not open.")
|
||||
val width = (pageSize.width * safeScale).roundToInt().coerceAtLeast(1)
|
||||
val height = (pageSize.height * safeScale).roundToInt().coerceAtLeast(1)
|
||||
val stride = width * 4
|
||||
|
|
@ -836,7 +878,8 @@ object DesktopPdfium {
|
|||
private fun extractDocumentMetadata(document: Pointer): DesktopPdfMetadata {
|
||||
return DesktopPdfMetadata(
|
||||
title = documentMetaText(document, "Title").cleanPdfMetadata(),
|
||||
author = documentMetaText(document, "Author").cleanPdfMetadata()
|
||||
author = documentMetaText(document, "Author").cleanPdfMetadata(),
|
||||
description = documentMetaText(document, "Subject").cleanPdfMetadata()
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -900,18 +943,26 @@ object DesktopPdfium {
|
|||
pageSizes: List<DesktopPdfPageSize>
|
||||
): List<SharedPdfEmbeddedAnnotation> {
|
||||
return pageSizes.flatMapIndexed { pageIndex, pageSize ->
|
||||
runCatching {
|
||||
loadPage(document, pageIndex).usePointer { page ->
|
||||
val count = api.FPDFPage_GetAnnotCount(page).coerceAtLeast(0)
|
||||
val rawAnnotations = (0 until count).mapNotNull { index ->
|
||||
extractEmbeddedAnnotation(page, pageIndex, index, pageSize)
|
||||
}
|
||||
SharedPdfEmbeddedAnnotationThreads.group(rawAnnotations)
|
||||
}
|
||||
}.getOrDefault(emptyList())
|
||||
extractEmbeddedAnnotationsForPage(document, pageIndex, pageSize)
|
||||
}
|
||||
}
|
||||
|
||||
private fun extractEmbeddedAnnotationsForPage(
|
||||
document: Pointer,
|
||||
pageIndex: Int,
|
||||
pageSize: DesktopPdfPageSize
|
||||
): List<SharedPdfEmbeddedAnnotation> {
|
||||
return runCatching {
|
||||
loadPage(document, pageIndex).usePointer { page ->
|
||||
val count = api.FPDFPage_GetAnnotCount(page).coerceAtLeast(0)
|
||||
val rawAnnotations = (0 until count).mapNotNull { index ->
|
||||
extractEmbeddedAnnotation(page, pageIndex, index, pageSize)
|
||||
}
|
||||
SharedPdfEmbeddedAnnotationThreads.group(rawAnnotations)
|
||||
}
|
||||
}.getOrDefault(emptyList())
|
||||
}
|
||||
|
||||
private fun extractEmbeddedAnnotation(
|
||||
page: Pointer,
|
||||
pageIndex: Int,
|
||||
|
|
@ -996,6 +1047,19 @@ object DesktopPdfium {
|
|||
return PointerResource(page, api::FPDF_ClosePage)
|
||||
}
|
||||
|
||||
private fun pageSizeByIndex(document: Pointer, pageIndex: Int): DesktopPdfPageSize? {
|
||||
val width = DoubleArray(1)
|
||||
val height = DoubleArray(1)
|
||||
val loaded = runCatching {
|
||||
api.FPDF_GetPageSizeByIndex(document, pageIndex, width, height)
|
||||
}.getOrDefault(0)
|
||||
return if (loaded != 0 && width[0] > 0.0 && height[0] > 0.0) {
|
||||
DesktopPdfPageSize(width[0].toFloat(), height[0].toFloat())
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun initLibrary() {
|
||||
if (!initialized) {
|
||||
api.FPDF_InitLibrary()
|
||||
|
|
@ -1004,14 +1068,21 @@ object DesktopPdfium {
|
|||
}
|
||||
|
||||
private fun resolvePdfiumDll(): File {
|
||||
val overridePath = System.getProperty("reader.pdfium.dll")
|
||||
val overridePath = System.getProperty("reader.pdfium.path")
|
||||
?: System.getenv("READER_PDFIUM_PATH")
|
||||
?: System.getProperty("reader.pdfium.dll")
|
||||
?: System.getenv("READER_PDFIUM_DLL")
|
||||
if (!overridePath.isNullOrBlank()) {
|
||||
return File(overridePath).absoluteFile
|
||||
}
|
||||
|
||||
val relativePath = listOf("third_party", "pdfium", "win-x64-v8", "bin", "pdfium.dll")
|
||||
.joinToString(File.separator)
|
||||
val platform = currentDesktopPlatform()
|
||||
val relativePath = desktopPdfiumRelativePath(platform)
|
||||
val resourceDir = System.getProperty(ComposeApplicationResourcesDirProperty)
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?.let(::File)
|
||||
resourceDir?.resolve(relativePath)?.absoluteFile?.takeIf { it.exists() }?.let { return it }
|
||||
|
||||
val roots = generateSequence(File(System.getProperty("user.dir")).absoluteFile) { it.parentFile }
|
||||
.take(6)
|
||||
.toList()
|
||||
|
|
@ -1022,6 +1093,22 @@ object DesktopPdfium {
|
|||
?: File(File(System.getProperty("user.dir")).absoluteFile, relativePath).absoluteFile
|
||||
}
|
||||
|
||||
private fun desktopPdfiumRelativePath(platform: DesktopPlatform): String {
|
||||
return listOf(
|
||||
"third_party",
|
||||
"pdfium",
|
||||
platform.pdfiumDirectoryName,
|
||||
platform.pdfiumLibraryDirectoryName,
|
||||
platform.pdfiumLibraryFileName
|
||||
).joinToString(File.separator)
|
||||
}
|
||||
|
||||
private fun missingPdfiumLibraryMessage(expectedFile: File): String {
|
||||
val platform = currentDesktopPlatform()
|
||||
return "Missing Pdfium library for ${platform.os.name.lowercase()}-${platform.architecture.resourceName}. " +
|
||||
"Expected ${expectedFile.absolutePath}. You can also set reader.pdfium.path or READER_PDFIUM_PATH."
|
||||
}
|
||||
|
||||
private fun Memory.toBufferedImage(width: Int, height: Int, stride: Int): BufferedImage {
|
||||
val image = BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB)
|
||||
val buffer = getByteBuffer(0, size()).order(ByteOrder.LITTLE_ENDIAN)
|
||||
|
|
@ -1056,11 +1143,11 @@ object DesktopPdfium {
|
|||
}
|
||||
|
||||
private fun logPdfiumOpen(message: String) {
|
||||
println("DesktopPdfiumOpen $message")
|
||||
logDesktopDiagnostic("DesktopPdfiumOpen") { message }
|
||||
}
|
||||
|
||||
private fun logPdfiumLink(message: String) {
|
||||
println("DesktopPdfiumLink $message")
|
||||
logDesktopDiagnostic("DesktopPdfiumLink") { message }
|
||||
}
|
||||
|
||||
private fun Float.formatLogFloat(): String {
|
||||
|
|
@ -1203,6 +1290,7 @@ object DesktopPdfium {
|
|||
fun FPDF_GetLastError(): Int
|
||||
fun FPDF_GetMetaText(document: Pointer, tag: String, buffer: Pointer?, buflen: Int): Int
|
||||
fun FPDF_GetPageCount(document: Pointer): Int
|
||||
fun FPDF_GetPageSizeByIndex(document: Pointer, pageIndex: Int, width: DoubleArray, height: DoubleArray): Int
|
||||
fun FPDFBookmark_GetFirstChild(document: Pointer, bookmark: Pointer?): Pointer?
|
||||
fun FPDFBookmark_GetNextSibling(document: Pointer, bookmark: Pointer): Pointer?
|
||||
fun FPDFBookmark_GetTitle(bookmark: Pointer, buffer: Pointer?, buflen: Int): Int
|
||||
|
|
|
|||
|
|
@ -0,0 +1,146 @@
|
|||
package com.aryan.reader.desktop
|
||||
|
||||
import java.io.File
|
||||
import java.util.Locale
|
||||
|
||||
internal enum class DesktopOperatingSystem {
|
||||
WINDOWS,
|
||||
LINUX,
|
||||
MACOS,
|
||||
OTHER
|
||||
}
|
||||
|
||||
internal enum class DesktopArchitecture(val resourceName: String) {
|
||||
X64("x64"),
|
||||
ARM64("arm64"),
|
||||
X86("x86"),
|
||||
OTHER("unknown")
|
||||
}
|
||||
|
||||
internal data class DesktopPlatform(
|
||||
val os: DesktopOperatingSystem,
|
||||
val architecture: DesktopArchitecture
|
||||
) {
|
||||
val isLinux: Boolean get() = os == DesktopOperatingSystem.LINUX
|
||||
val isWindows: Boolean get() = os == DesktopOperatingSystem.WINDOWS
|
||||
|
||||
val kcefBundleDirectoryName: String
|
||||
get() = when (os) {
|
||||
DesktopOperatingSystem.WINDOWS -> "kcef-bundle"
|
||||
DesktopOperatingSystem.LINUX -> "kcef-bundle-linux-${architecture.resourceName}"
|
||||
DesktopOperatingSystem.MACOS -> "kcef-bundle-macos-${architecture.resourceName}"
|
||||
DesktopOperatingSystem.OTHER -> "kcef-bundle-${architecture.resourceName}"
|
||||
}
|
||||
|
||||
val pdfiumDirectoryName: String
|
||||
get() = when (os) {
|
||||
DesktopOperatingSystem.WINDOWS -> "win-${architecture.resourceName}-v8"
|
||||
DesktopOperatingSystem.LINUX -> "linux-${architecture.resourceName}-v8"
|
||||
DesktopOperatingSystem.MACOS -> "mac-${architecture.resourceName}-v8"
|
||||
DesktopOperatingSystem.OTHER -> "${architecture.resourceName}-v8"
|
||||
}
|
||||
|
||||
val pdfiumLibraryFileName: String
|
||||
get() = when (os) {
|
||||
DesktopOperatingSystem.WINDOWS -> "pdfium.dll"
|
||||
DesktopOperatingSystem.LINUX -> "libpdfium.so"
|
||||
DesktopOperatingSystem.MACOS -> "libpdfium.dylib"
|
||||
DesktopOperatingSystem.OTHER -> "pdfium"
|
||||
}
|
||||
|
||||
val pdfiumLibraryDirectoryName: String
|
||||
get() = when (os) {
|
||||
DesktopOperatingSystem.WINDOWS -> "bin"
|
||||
DesktopOperatingSystem.LINUX,
|
||||
DesktopOperatingSystem.MACOS,
|
||||
DesktopOperatingSystem.OTHER -> "lib"
|
||||
}
|
||||
}
|
||||
|
||||
internal fun currentDesktopPlatform(
|
||||
osName: String = System.getProperty("os.name").orEmpty(),
|
||||
osArch: String = System.getProperty("os.arch").orEmpty()
|
||||
): DesktopPlatform {
|
||||
return DesktopPlatform(
|
||||
os = desktopOperatingSystem(osName),
|
||||
architecture = desktopArchitecture(osArch)
|
||||
)
|
||||
}
|
||||
|
||||
internal fun desktopOperatingSystem(osName: String): DesktopOperatingSystem {
|
||||
val normalized = osName.trim().lowercase(Locale.ROOT)
|
||||
return when {
|
||||
normalized.startsWith("windows") -> DesktopOperatingSystem.WINDOWS
|
||||
normalized == "linux" || normalized.contains("linux") -> DesktopOperatingSystem.LINUX
|
||||
normalized.startsWith("mac") || normalized.contains("darwin") -> DesktopOperatingSystem.MACOS
|
||||
else -> DesktopOperatingSystem.OTHER
|
||||
}
|
||||
}
|
||||
|
||||
internal fun desktopArchitecture(osArch: String): DesktopArchitecture {
|
||||
return when (osArch.trim().lowercase(Locale.ROOT)) {
|
||||
"amd64", "x86_64", "x64" -> DesktopArchitecture.X64
|
||||
"aarch64", "arm64" -> DesktopArchitecture.ARM64
|
||||
"x86", "i386", "i686" -> DesktopArchitecture.X86
|
||||
else -> DesktopArchitecture.OTHER
|
||||
}
|
||||
}
|
||||
|
||||
internal fun desktopUserDataRoot(
|
||||
platform: DesktopPlatform = currentDesktopPlatform(),
|
||||
env: (String) -> String? = System::getenv,
|
||||
userHome: String = System.getProperty("user.home").orEmpty()
|
||||
): File {
|
||||
return when (platform.os) {
|
||||
DesktopOperatingSystem.WINDOWS -> File(windowsRoamingBase(env, userHome), "Episteme")
|
||||
DesktopOperatingSystem.LINUX -> File(xdgBase("XDG_DATA_HOME", ".local/share", env, userHome), "episteme")
|
||||
DesktopOperatingSystem.MACOS -> File(userHome, "Library/Application Support/Episteme")
|
||||
DesktopOperatingSystem.OTHER -> File(userHome, ".episteme")
|
||||
}
|
||||
}
|
||||
|
||||
internal fun desktopUserConfigRoot(
|
||||
platform: DesktopPlatform = currentDesktopPlatform(),
|
||||
env: (String) -> String? = System::getenv,
|
||||
userHome: String = System.getProperty("user.home").orEmpty()
|
||||
): File {
|
||||
return when (platform.os) {
|
||||
DesktopOperatingSystem.WINDOWS -> File(windowsRoamingBase(env, userHome), "Episteme")
|
||||
DesktopOperatingSystem.LINUX -> File(xdgBase("XDG_CONFIG_HOME", ".config", env, userHome), "episteme")
|
||||
DesktopOperatingSystem.MACOS -> File(userHome, "Library/Application Support/Episteme")
|
||||
DesktopOperatingSystem.OTHER -> File(userHome, ".episteme")
|
||||
}
|
||||
}
|
||||
|
||||
internal fun desktopUserCacheRoot(
|
||||
platform: DesktopPlatform = currentDesktopPlatform(),
|
||||
env: (String) -> String? = System::getenv,
|
||||
userHome: String = System.getProperty("user.home").orEmpty()
|
||||
): File {
|
||||
return when (platform.os) {
|
||||
DesktopOperatingSystem.WINDOWS -> File(windowsRoamingBase(env, userHome), "Episteme")
|
||||
DesktopOperatingSystem.LINUX -> File(xdgBase("XDG_CACHE_HOME", ".cache", env, userHome), "episteme")
|
||||
DesktopOperatingSystem.MACOS -> File(userHome, "Library/Caches/Episteme")
|
||||
DesktopOperatingSystem.OTHER -> File(userHome, ".episteme/cache")
|
||||
}
|
||||
}
|
||||
|
||||
private fun windowsRoamingBase(env: (String) -> String?, userHome: String): File {
|
||||
return env("APPDATA")
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?.let(::File)
|
||||
?: File(userHome, "AppData/Roaming")
|
||||
}
|
||||
|
||||
private fun xdgBase(
|
||||
envName: String,
|
||||
fallbackRelativePath: String,
|
||||
env: (String) -> String?,
|
||||
userHome: String
|
||||
): File {
|
||||
return env(envName)
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?.let(::File)
|
||||
?.takeIf { it.isAbsolute }
|
||||
?: File(userHome, fallbackRelativePath)
|
||||
}
|
||||
|
|
@ -0,0 +1,163 @@
|
|||
package com.aryan.reader.desktop
|
||||
|
||||
import java.awt.BorderLayout
|
||||
import java.awt.Color
|
||||
import java.awt.Component
|
||||
import java.awt.Dimension
|
||||
import java.awt.EventQueue
|
||||
import java.awt.Font
|
||||
import java.awt.GraphicsEnvironment
|
||||
import java.awt.Image
|
||||
import java.lang.reflect.InvocationTargetException
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
import javax.swing.BorderFactory
|
||||
import javax.swing.Box
|
||||
import javax.swing.BoxLayout
|
||||
import javax.swing.ImageIcon
|
||||
import javax.swing.JLabel
|
||||
import javax.swing.JPanel
|
||||
import javax.swing.JProgressBar
|
||||
import javax.swing.JWindow
|
||||
import javax.swing.SwingConstants
|
||||
|
||||
internal data class DesktopStartupSplashSpec(
|
||||
val title: String = EpistemeDesktopWindowTitle,
|
||||
val message: String = "Opening your library",
|
||||
val width: Int = 360,
|
||||
val height: Int = 220
|
||||
)
|
||||
|
||||
internal fun epistemeDesktopStartupSplashSpec(
|
||||
profile: DesktopBuildProfile = currentDesktopBuildProfile()
|
||||
): DesktopStartupSplashSpec {
|
||||
return DesktopStartupSplashSpec(title = profile.appName)
|
||||
}
|
||||
|
||||
internal class DesktopStartupSplash private constructor(
|
||||
private val window: JWindow
|
||||
) {
|
||||
fun close() {
|
||||
runOnSplashEventThread {
|
||||
window.isVisible = false
|
||||
window.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun show(spec: DesktopStartupSplashSpec = epistemeDesktopStartupSplashSpec()): DesktopStartupSplash? {
|
||||
if (GraphicsEnvironment.isHeadless()) return null
|
||||
|
||||
val splashRef = AtomicReference<DesktopStartupSplash?>()
|
||||
runOnSplashEventThreadAndWait {
|
||||
runCatching {
|
||||
val window = JWindow().apply {
|
||||
name = "episteme-startup-splash"
|
||||
preferredSize = Dimension(spec.width, spec.height)
|
||||
minimumSize = Dimension(spec.width, spec.height)
|
||||
background = SplashBackground
|
||||
contentPane = startupSplashContent(spec)
|
||||
pack()
|
||||
setLocationRelativeTo(null)
|
||||
isAlwaysOnTop = true
|
||||
isVisible = true
|
||||
}
|
||||
splashRef.set(DesktopStartupSplash(window))
|
||||
}
|
||||
}
|
||||
return splashRef.get()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun startupSplashContent(spec: DesktopStartupSplashSpec): JPanel {
|
||||
return JPanel(BorderLayout()).apply {
|
||||
preferredSize = Dimension(spec.width, spec.height)
|
||||
background = SplashBackground
|
||||
border = BorderFactory.createLineBorder(SplashBorder)
|
||||
|
||||
val body = JPanel().apply {
|
||||
background = SplashBackground
|
||||
layout = BoxLayout(this, BoxLayout.Y_AXIS)
|
||||
border = BorderFactory.createEmptyBorder(24, 28, 22, 28)
|
||||
}
|
||||
|
||||
startupSplashIcon()?.let { icon ->
|
||||
body.add(
|
||||
JLabel(icon).apply {
|
||||
alignmentX = Component.CENTER_ALIGNMENT
|
||||
horizontalAlignment = SwingConstants.CENTER
|
||||
}
|
||||
)
|
||||
body.add(Box.createVerticalStrut(14))
|
||||
}
|
||||
|
||||
body.add(
|
||||
JLabel(spec.title).apply {
|
||||
alignmentX = Component.CENTER_ALIGNMENT
|
||||
horizontalAlignment = SwingConstants.CENTER
|
||||
foreground = SplashTitle
|
||||
font = font.deriveFont(Font.BOLD, 24f)
|
||||
}
|
||||
)
|
||||
body.add(Box.createVerticalStrut(8))
|
||||
body.add(
|
||||
JLabel(spec.message).apply {
|
||||
alignmentX = Component.CENTER_ALIGNMENT
|
||||
horizontalAlignment = SwingConstants.CENTER
|
||||
foreground = SplashText
|
||||
font = font.deriveFont(Font.PLAIN, 13f)
|
||||
}
|
||||
)
|
||||
body.add(Box.createVerticalStrut(20))
|
||||
body.add(
|
||||
JProgressBar().apply {
|
||||
alignmentX = Component.CENTER_ALIGNMENT
|
||||
isIndeterminate = true
|
||||
isBorderPainted = false
|
||||
preferredSize = Dimension(220, 8)
|
||||
maximumSize = Dimension(220, 8)
|
||||
foreground = SplashAccent
|
||||
background = SplashTrack
|
||||
}
|
||||
)
|
||||
|
||||
add(body, BorderLayout.CENTER)
|
||||
}
|
||||
}
|
||||
|
||||
private fun startupSplashIcon(): ImageIcon? {
|
||||
val resource = Thread.currentThread().contextClassLoader?.getResource(EpistemeDesktopWindowIconResource)
|
||||
?: DesktopStartupSplash::class.java.classLoader?.getResource(EpistemeDesktopWindowIconResource)
|
||||
?: return null
|
||||
val icon = ImageIcon(resource)
|
||||
return ImageIcon(icon.image.getScaledInstance(56, 56, Image.SCALE_SMOOTH))
|
||||
}
|
||||
|
||||
private fun runOnSplashEventThread(block: () -> Unit) {
|
||||
if (EventQueue.isDispatchThread()) {
|
||||
block()
|
||||
} else {
|
||||
EventQueue.invokeLater { block() }
|
||||
}
|
||||
}
|
||||
|
||||
private fun runOnSplashEventThreadAndWait(block: () -> Unit) {
|
||||
if (EventQueue.isDispatchThread()) {
|
||||
block()
|
||||
return
|
||||
}
|
||||
try {
|
||||
EventQueue.invokeAndWait { block() }
|
||||
} catch (_: InterruptedException) {
|
||||
Thread.currentThread().interrupt()
|
||||
} catch (_: InvocationTargetException) {
|
||||
// Startup feedback should never prevent the app from launching.
|
||||
}
|
||||
}
|
||||
|
||||
private val SplashBackground = Color(0xF9, 0xF7, 0xEF)
|
||||
private val SplashBorder = Color(0xD8, 0xD2, 0xC3)
|
||||
private val SplashTitle = Color(0x1E, 0x22, 0x1A)
|
||||
private val SplashText = Color(0x61, 0x64, 0x58)
|
||||
private val SplashAccent = Color(0x2F, 0x6F, 0x68)
|
||||
private val SplashTrack = Color(0xE3, 0xDE, 0xD1)
|
||||
|
|
@ -3,7 +3,7 @@ package com.aryan.reader.desktop
|
|||
private const val DesktopTtsLogTag = "EpistemeDesktopTts"
|
||||
|
||||
internal fun logDesktopTts(message: String) {
|
||||
println("$DesktopTtsLogTag $message")
|
||||
logDesktopDiagnostic(DesktopTtsLogTag) { message }
|
||||
}
|
||||
|
||||
internal fun Throwable.desktopTtsSummary(): String {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,291 @@
|
|||
package com.aryan.reader.desktop
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.luminance
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.unit.DpSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.sun.jna.Native
|
||||
import com.sun.jna.Pointer
|
||||
import com.sun.jna.ptr.IntByReference
|
||||
import com.sun.jna.win32.StdCallLibrary
|
||||
import java.awt.Component
|
||||
import java.awt.Container
|
||||
import java.awt.Dimension
|
||||
import java.awt.EventQueue
|
||||
import java.awt.Color as AwtColor
|
||||
import java.awt.Window as AwtWindow
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
import javax.swing.RootPaneContainer
|
||||
import javax.swing.SwingUtilities
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
internal const val EpistemeDesktopWindowTitle = EpistemeDesktopStandardAppName
|
||||
internal const val EpistemeDesktopWindowIconResource = "episteme_icon.png"
|
||||
internal const val EpistemeDesktopWindowMinimumWidthPx = 960
|
||||
internal const val EpistemeDesktopWindowMinimumHeightPx = 640
|
||||
|
||||
internal data class DesktopWindowDefaults(
|
||||
val title: String,
|
||||
val defaultSize: DpSize,
|
||||
val minimumSize: Dimension,
|
||||
val iconResourcePath: String
|
||||
)
|
||||
|
||||
internal fun epistemeDesktopWindowDefaults(
|
||||
profile: DesktopBuildProfile = currentDesktopBuildProfile()
|
||||
): DesktopWindowDefaults {
|
||||
return DesktopWindowDefaults(
|
||||
title = profile.appName,
|
||||
defaultSize = DpSize(1280.dp, 820.dp),
|
||||
minimumSize = Dimension(EpistemeDesktopWindowMinimumWidthPx, EpistemeDesktopWindowMinimumHeightPx),
|
||||
iconResourcePath = EpistemeDesktopWindowIconResource
|
||||
)
|
||||
}
|
||||
|
||||
internal data class DesktopWindowChromeColors(
|
||||
val useDarkMode: Boolean,
|
||||
val captionColorRef: Int,
|
||||
val textColorRef: Int,
|
||||
val borderColorRef: Int
|
||||
)
|
||||
|
||||
internal fun desktopWindowChromeColors(
|
||||
captionColor: Color,
|
||||
textColor: Color,
|
||||
borderColor: Color
|
||||
): DesktopWindowChromeColors {
|
||||
return DesktopWindowChromeColors(
|
||||
useDarkMode = captionColor.luminance() < 0.5f,
|
||||
captionColorRef = captionColor.toWindowsColorRef(),
|
||||
textColorRef = textColor.toWindowsColorRef(),
|
||||
borderColorRef = borderColor.toWindowsColorRef()
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun EpistemeDesktopWindowChromeEffect(
|
||||
window: Component?,
|
||||
captionColor: Color,
|
||||
textColor: Color,
|
||||
borderColor: Color
|
||||
) {
|
||||
DisposableEffect(window, captionColor, textColor, borderColor) {
|
||||
applyDesktopWindowBackground(window, borderColor)
|
||||
applyWindowsDesktopWindowChrome(
|
||||
window = window,
|
||||
colors = desktopWindowChromeColors(
|
||||
captionColor = captionColor,
|
||||
textColor = textColor,
|
||||
borderColor = borderColor
|
||||
)
|
||||
)
|
||||
onDispose {}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun EpistemeDesktopWindowDecorationEffect(
|
||||
window: Component?,
|
||||
hideDecoration: Boolean
|
||||
) {
|
||||
val originalStyle = remember(window) { AtomicReference<Int?>(null) }
|
||||
LaunchedEffect(window, hideDecoration) {
|
||||
delay(if (hideDecoration) 120L else 80L)
|
||||
applyWindowsDesktopWindowDecoration(
|
||||
window = window,
|
||||
hideDecoration = hideDecoration,
|
||||
originalStyle = originalStyle
|
||||
)
|
||||
}
|
||||
DisposableEffect(window, hideDecoration) {
|
||||
onDispose {
|
||||
if (hideDecoration) {
|
||||
applyWindowsDesktopWindowDecoration(
|
||||
window = window,
|
||||
hideDecoration = false,
|
||||
originalStyle = originalStyle
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun isWindowsDesktop(osName: String = System.getProperty("os.name").orEmpty()): Boolean {
|
||||
return osName.startsWith("Windows", ignoreCase = true)
|
||||
}
|
||||
|
||||
private fun applyDesktopWindowBackground(window: Component?, color: Color) {
|
||||
val awtColor = color.toAwtOpaqueColor()
|
||||
runOnEventDispatchThread {
|
||||
val awtWindow = window.toAwtWindowOrNull()
|
||||
window?.background = awtColor
|
||||
awtWindow?.background = awtColor
|
||||
(awtWindow as? Container)?.background = awtColor
|
||||
(awtWindow as? RootPaneContainer)?.let { rootPaneContainer ->
|
||||
rootPaneContainer.contentPane.background = awtColor
|
||||
rootPaneContainer.rootPane.background = awtColor
|
||||
rootPaneContainer.layeredPane.background = awtColor
|
||||
rootPaneContainer.glassPane.background = awtColor
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun applyWindowsDesktopWindowChrome(
|
||||
window: Component?,
|
||||
colors: DesktopWindowChromeColors,
|
||||
osName: String = System.getProperty("os.name").orEmpty()
|
||||
) {
|
||||
if (!isWindowsDesktop(osName)) return
|
||||
runOnEventDispatchThread {
|
||||
val awtWindow = window.toAwtWindowOrNull() ?: return@runOnEventDispatchThread
|
||||
val hwnd = runCatching { Native.getWindowPointer(awtWindow) }.getOrNull() ?: return@runOnEventDispatchThread
|
||||
WindowsDwmApi.applyWindowChrome(hwnd, colors)
|
||||
}
|
||||
}
|
||||
|
||||
private fun applyWindowsDesktopWindowDecoration(
|
||||
window: Component?,
|
||||
hideDecoration: Boolean,
|
||||
originalStyle: AtomicReference<Int?>,
|
||||
osName: String = System.getProperty("os.name").orEmpty()
|
||||
) {
|
||||
if (!isWindowsDesktop(osName)) return
|
||||
EventQueue.invokeLater decoration@{
|
||||
val awtWindow = window.toAwtWindowOrNull() ?: return@decoration
|
||||
val hwnd = runCatching { Native.getWindowPointer(awtWindow) }.getOrNull() ?: return@decoration
|
||||
val api = runCatching { User32Api.INSTANCE }.getOrNull() ?: return@decoration
|
||||
if (hideDecoration) {
|
||||
val style = api.GetWindowLongW(hwnd, GWL_STYLE)
|
||||
originalStyle.compareAndSet(null, style)
|
||||
val fullscreenStyle = style and WS_CAPTION.inv() and WS_THICKFRAME.inv()
|
||||
if (fullscreenStyle != style) {
|
||||
api.SetWindowLongW(hwnd, GWL_STYLE, fullscreenStyle)
|
||||
api.SetWindowPos(
|
||||
hwnd,
|
||||
null,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
SWP_NOMOVE or SWP_NOSIZE or SWP_NOZORDER or SWP_NOACTIVATE or SWP_FRAMECHANGED
|
||||
)
|
||||
}
|
||||
} else {
|
||||
val restoredStyle = originalStyle.getAndSet(null) ?: return@decoration
|
||||
api.SetWindowLongW(hwnd, GWL_STYLE, restoredStyle)
|
||||
api.SetWindowPos(
|
||||
hwnd,
|
||||
null,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
SWP_NOMOVE or SWP_NOSIZE or SWP_NOZORDER or SWP_NOACTIVATE or SWP_FRAMECHANGED
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Component?.toAwtWindowOrNull(): AwtWindow? {
|
||||
return when (this) {
|
||||
null -> null
|
||||
is AwtWindow -> this
|
||||
else -> SwingUtilities.getWindowAncestor(this)
|
||||
}
|
||||
}
|
||||
|
||||
private fun runOnEventDispatchThread(block: () -> Unit) {
|
||||
if (EventQueue.isDispatchThread()) {
|
||||
block()
|
||||
} else {
|
||||
EventQueue.invokeLater(block)
|
||||
}
|
||||
}
|
||||
|
||||
private fun Color.toAwtOpaqueColor(): AwtColor {
|
||||
val argb = toArgb()
|
||||
return AwtColor(
|
||||
(argb shr 16) and 0xFF,
|
||||
(argb shr 8) and 0xFF,
|
||||
argb and 0xFF
|
||||
)
|
||||
}
|
||||
|
||||
private fun Color.toWindowsColorRef(): Int {
|
||||
val argb = toArgb()
|
||||
val red = (argb shr 16) and 0xFF
|
||||
val green = (argb shr 8) and 0xFF
|
||||
val blue = argb and 0xFF
|
||||
return red or (green shl 8) or (blue shl 16)
|
||||
}
|
||||
|
||||
private const val GWL_STYLE = -16
|
||||
private const val WS_CAPTION = 0x00C00000
|
||||
private const val WS_THICKFRAME = 0x00040000
|
||||
private const val SWP_NOSIZE = 0x0001
|
||||
private const val SWP_NOMOVE = 0x0002
|
||||
private const val SWP_NOZORDER = 0x0004
|
||||
private const val SWP_NOACTIVATE = 0x0010
|
||||
private const val SWP_FRAMECHANGED = 0x0020
|
||||
|
||||
private object WindowsDwmApi {
|
||||
private const val DWMWA_USE_IMMERSIVE_DARK_MODE_BEFORE_20H1 = 19
|
||||
private const val DWMWA_USE_IMMERSIVE_DARK_MODE = 20
|
||||
private const val DWMWA_BORDER_COLOR = 34
|
||||
private const val DWMWA_CAPTION_COLOR = 35
|
||||
private const val DWMWA_TEXT_COLOR = 36
|
||||
|
||||
fun applyWindowChrome(hwnd: Pointer, colors: DesktopWindowChromeColors) {
|
||||
val api = runCatching { DwmApi.INSTANCE }.getOrNull() ?: return
|
||||
val darkModeValue = if (colors.useDarkMode) 1 else 0
|
||||
val darkModeResult = api.setIntAttribute(hwnd, DWMWA_USE_IMMERSIVE_DARK_MODE, darkModeValue)
|
||||
if (darkModeResult != 0) {
|
||||
api.setIntAttribute(hwnd, DWMWA_USE_IMMERSIVE_DARK_MODE_BEFORE_20H1, darkModeValue)
|
||||
}
|
||||
api.setIntAttribute(hwnd, DWMWA_CAPTION_COLOR, colors.captionColorRef)
|
||||
api.setIntAttribute(hwnd, DWMWA_TEXT_COLOR, colors.textColorRef)
|
||||
api.setIntAttribute(hwnd, DWMWA_BORDER_COLOR, colors.borderColorRef)
|
||||
}
|
||||
|
||||
private fun DwmApi.setIntAttribute(hwnd: Pointer, attribute: Int, value: Int): Int {
|
||||
return runCatching {
|
||||
val ref = IntByReference(value)
|
||||
DwmSetWindowAttribute(hwnd, attribute, ref.pointer, Int.SIZE_BYTES)
|
||||
}.getOrDefault(-1)
|
||||
}
|
||||
}
|
||||
|
||||
private interface DwmApi : StdCallLibrary {
|
||||
fun DwmSetWindowAttribute(hwnd: Pointer, attribute: Int, value: Pointer, valueSize: Int): Int
|
||||
|
||||
companion object {
|
||||
val INSTANCE: DwmApi by lazy {
|
||||
Native.load("dwmapi", DwmApi::class.java) as DwmApi
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private interface User32Api : StdCallLibrary {
|
||||
fun GetWindowLongW(hwnd: Pointer, index: Int): Int
|
||||
fun SetWindowLongW(hwnd: Pointer, index: Int, value: Int): Int
|
||||
fun SetWindowPos(
|
||||
hwnd: Pointer,
|
||||
insertAfter: Pointer?,
|
||||
x: Int,
|
||||
y: Int,
|
||||
cx: Int,
|
||||
cy: Int,
|
||||
flags: Int
|
||||
): Boolean
|
||||
|
||||
companion object {
|
||||
val INSTANCE: User32Api by lazy {
|
||||
Native.load("user32", User32Api::class.java) as User32Api
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,155 @@
|
|||
package com.aryan.reader.desktop
|
||||
|
||||
import androidx.compose.ui.unit.DpSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.WindowPlacement
|
||||
import androidx.compose.ui.window.WindowPosition
|
||||
import androidx.compose.ui.window.WindowState
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.floatOrNull
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import java.io.File
|
||||
|
||||
private const val DesktopWindowStateSchemaVersion = 1
|
||||
|
||||
internal enum class DesktopSavedWindowPlacement {
|
||||
FLOATING,
|
||||
MAXIMIZED,
|
||||
FULLSCREEN
|
||||
}
|
||||
|
||||
internal data class DesktopWindowStateSnapshot(
|
||||
val placement: DesktopSavedWindowPlacement,
|
||||
val widthDp: Float,
|
||||
val heightDp: Float,
|
||||
val xDp: Float? = null,
|
||||
val yDp: Float? = null
|
||||
) {
|
||||
fun toWindowPlacement(): WindowPlacement {
|
||||
return when (placement) {
|
||||
DesktopSavedWindowPlacement.FLOATING -> WindowPlacement.Floating
|
||||
DesktopSavedWindowPlacement.MAXIMIZED -> WindowPlacement.Maximized
|
||||
DesktopSavedWindowPlacement.FULLSCREEN -> WindowPlacement.Fullscreen
|
||||
}
|
||||
}
|
||||
|
||||
fun toWindowSize(defaultSize: DpSize): DpSize {
|
||||
val width = widthDp.takeIf { it.isFinite() && it >= EpistemeDesktopWindowMinimumWidthPx.toFloat() }
|
||||
val height = heightDp.takeIf { it.isFinite() && it >= EpistemeDesktopWindowMinimumHeightPx.toFloat() }
|
||||
return DpSize(
|
||||
width = width?.dp ?: defaultSize.width,
|
||||
height = height?.dp ?: defaultSize.height
|
||||
)
|
||||
}
|
||||
|
||||
fun toWindowPosition(): WindowPosition {
|
||||
val x = xDp?.takeIf { it.isFinite() } ?: return WindowPosition.PlatformDefault
|
||||
val y = yDp?.takeIf { it.isFinite() } ?: return WindowPosition.PlatformDefault
|
||||
return WindowPosition(x.dp, y.dp)
|
||||
}
|
||||
|
||||
fun sanitized(): DesktopWindowStateSnapshot {
|
||||
return copy(
|
||||
widthDp = widthDp.takeIf { it.isFinite() }?.coerceAtLeast(EpistemeDesktopWindowMinimumWidthPx.toFloat())
|
||||
?: EpistemeDesktopWindowMinimumWidthPx.toFloat(),
|
||||
heightDp = heightDp.takeIf { it.isFinite() }?.coerceAtLeast(EpistemeDesktopWindowMinimumHeightPx.toFloat())
|
||||
?: EpistemeDesktopWindowMinimumHeightPx.toFloat(),
|
||||
xDp = xDp?.takeIf { it.isFinite() },
|
||||
yDp = yDp?.takeIf { it.isFinite() }
|
||||
)
|
||||
}
|
||||
|
||||
fun toJsonObject(): JsonObject {
|
||||
val sanitized = sanitized()
|
||||
return JsonObject(
|
||||
buildMap {
|
||||
put("schemaVersion", JsonPrimitive(DesktopWindowStateSchemaVersion))
|
||||
put("placement", JsonPrimitive(sanitized.placement.name))
|
||||
put("widthDp", JsonPrimitive(sanitized.widthDp))
|
||||
put("heightDp", JsonPrimitive(sanitized.heightDp))
|
||||
put("xDp", sanitized.xDp?.let { JsonPrimitive(it) } ?: JsonNull)
|
||||
put("yDp", sanitized.yDp?.let { JsonPrimitive(it) } ?: JsonNull)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun default(): DesktopWindowStateSnapshot {
|
||||
return DesktopWindowStateSnapshot(
|
||||
placement = DesktopSavedWindowPlacement.MAXIMIZED,
|
||||
widthDp = 1280f,
|
||||
heightDp = 820f
|
||||
)
|
||||
}
|
||||
|
||||
fun fromWindowState(state: WindowState): DesktopWindowStateSnapshot? {
|
||||
if (state.isMinimized) return null
|
||||
val size = state.size
|
||||
val width = size.width.value.takeIf { it.isFinite() } ?: return null
|
||||
val height = size.height.value.takeIf { it.isFinite() } ?: return null
|
||||
val placement = when (state.placement) {
|
||||
WindowPlacement.Floating -> DesktopSavedWindowPlacement.FLOATING
|
||||
WindowPlacement.Maximized -> DesktopSavedWindowPlacement.MAXIMIZED
|
||||
WindowPlacement.Fullscreen -> DesktopSavedWindowPlacement.FULLSCREEN
|
||||
}
|
||||
val position = state.position.takeIf { it.isSpecified }
|
||||
return DesktopWindowStateSnapshot(
|
||||
placement = placement,
|
||||
widthDp = width,
|
||||
heightDp = height,
|
||||
xDp = position?.x?.value?.takeIf { it.isFinite() },
|
||||
yDp = position?.y?.value?.takeIf { it.isFinite() }
|
||||
).sanitized()
|
||||
}
|
||||
|
||||
fun fromJsonElement(element: JsonElement): DesktopWindowStateSnapshot? {
|
||||
val obj = runCatching { element.jsonObject }.getOrNull() ?: return null
|
||||
val placement = obj["placement"]
|
||||
?.jsonPrimitive
|
||||
?.content
|
||||
?.let { runCatching { DesktopSavedWindowPlacement.valueOf(it) }.getOrNull() }
|
||||
?: DesktopSavedWindowPlacement.MAXIMIZED
|
||||
val width = obj["widthDp"]?.jsonPrimitive?.floatOrNull ?: return null
|
||||
val height = obj["heightDp"]?.jsonPrimitive?.floatOrNull ?: return null
|
||||
return DesktopWindowStateSnapshot(
|
||||
placement = placement,
|
||||
widthDp = width,
|
||||
heightDp = height,
|
||||
xDp = obj["xDp"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.floatOrNull,
|
||||
yDp = obj["yDp"]?.takeUnless { it is JsonNull }?.jsonPrimitive?.floatOrNull
|
||||
).sanitized()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal class DesktopWindowStateStore(
|
||||
private val stateFile: File = defaultWindowStateFile()
|
||||
) {
|
||||
private val json = Json {
|
||||
prettyPrint = true
|
||||
ignoreUnknownKeys = true
|
||||
}
|
||||
|
||||
fun load(): DesktopWindowStateSnapshot? {
|
||||
if (!stateFile.exists()) return null
|
||||
return runCatching {
|
||||
DesktopWindowStateSnapshot.fromJsonElement(json.parseToJsonElement(stateFile.readText()))
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
fun save(snapshot: DesktopWindowStateSnapshot) {
|
||||
stateFile.parentFile?.mkdirs()
|
||||
stateFile.writeText(json.encodeToString(JsonElement.serializer(), snapshot.toJsonObject()))
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun defaultWindowStateFile(): File {
|
||||
return File(desktopUserConfigRoot(), "window_state.json")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
package com.aryan.reader.desktop
|
||||
|
||||
fun main() {
|
||||
val startupSplash = DesktopStartupSplash.show()
|
||||
launchEpistemeDesktopApplication(startupSplash)
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
BIN
desktopApp/src/desktopMain/resources/episteme.ico
Normal file
BIN
desktopApp/src/desktopMain/resources/episteme.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
BIN
desktopApp/src/desktopMain/resources/episteme_icon.png
Normal file
BIN
desktopApp/src/desktopMain/resources/episteme_icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
|
|
@ -86,6 +86,17 @@ class DesktopAiByokStoreTest {
|
|||
assertEquals(GEMINI_CLOUD_TTS_MODEL_ID, loaded.ttsModel)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `load does not probe secure storage when settings file is missing`() {
|
||||
val settingsFile = Files.createTempDirectory("reader-ai-store-missing").resolve("ai-byok.properties")
|
||||
val store = DesktopAiByokStore(settingsFile.toFile(), ThrowingAvailabilitySecretCodec)
|
||||
|
||||
val loaded = store.load()
|
||||
|
||||
assertEquals("", loaded.geminiKey)
|
||||
assertEquals("", loaded.groqKey)
|
||||
}
|
||||
|
||||
private object ReversibleSecretCodec : DesktopSecretCodec {
|
||||
override val isAvailable: Boolean = true
|
||||
|
||||
|
|
@ -103,4 +114,12 @@ class DesktopAiByokStoreTest {
|
|||
override fun protect(value: String): String = ""
|
||||
override fun unprotect(value: String): String = ""
|
||||
}
|
||||
|
||||
private object ThrowingAvailabilitySecretCodec : DesktopSecretCodec {
|
||||
override val isAvailable: Boolean
|
||||
get() = error("Secure storage should not be checked for a missing settings file.")
|
||||
|
||||
override fun protect(value: String): String = ""
|
||||
override fun unprotect(value: String): String = ""
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,76 @@
|
|||
package com.aryan.reader.desktop
|
||||
|
||||
import com.aryan.reader.shared.ImportedBookFile
|
||||
import java.io.File
|
||||
import java.nio.file.Files
|
||||
import java.security.MessageDigest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNotEquals
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
|
||||
class DesktopBookImporterTest {
|
||||
@Test
|
||||
fun `prepare imports copies supported file into app storage without source folder`() {
|
||||
val tempRoot = Files.createTempDirectory("episteme-book-importer-test").toFile()
|
||||
try {
|
||||
val source = File(tempRoot, "Book.md").apply { writeText("# Hello") }
|
||||
val store = File(tempRoot, "books")
|
||||
val importer = DesktopBookImporter(store)
|
||||
|
||||
val result = importer.prepareImports(listOf(source.toImportedBookFile()))
|
||||
|
||||
assertEquals(0, result.failedCount)
|
||||
val prepared = result.files.single()
|
||||
val copied = File(assertNotNull(prepared.localPath))
|
||||
assertEquals("Book.md", prepared.name)
|
||||
assertEquals(source.sha256(), prepared.id)
|
||||
assertNull(prepared.uriString)
|
||||
assertNull(prepared.sourceFolder)
|
||||
assertEquals(store.canonicalFile, copied.parentFile.canonicalFile)
|
||||
assertNotEquals(source.canonicalFile, copied.canonicalFile)
|
||||
assertEquals("# Hello", copied.readText())
|
||||
} finally {
|
||||
tempRoot.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `prepare imports leaves unsupported files uncopied for shared planner`() {
|
||||
val tempRoot = Files.createTempDirectory("episteme-book-importer-test").toFile()
|
||||
try {
|
||||
val source = File(tempRoot, "Archive.zip").apply { writeText("zip") }
|
||||
val store = File(tempRoot, "books")
|
||||
val importer = DesktopBookImporter(store)
|
||||
|
||||
val result = importer.prepareImports(listOf(source.toImportedBookFile(sourceFolder = tempRoot.absolutePath)))
|
||||
|
||||
assertEquals(0, result.failedCount)
|
||||
val prepared = result.files.single()
|
||||
assertEquals(source.absolutePath, prepared.localPath)
|
||||
assertNull(prepared.sourceFolder)
|
||||
assertNull(prepared.id)
|
||||
assertFalse(store.listFiles().orEmpty().any { it.isFile })
|
||||
} finally {
|
||||
tempRoot.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
private fun File.toImportedBookFile(sourceFolder: String? = null): ImportedBookFile {
|
||||
return ImportedBookFile(
|
||||
name = name,
|
||||
uriString = null,
|
||||
localPath = absolutePath,
|
||||
size = length(),
|
||||
sourceFolder = sourceFolder
|
||||
)
|
||||
}
|
||||
|
||||
private fun File.sha256(): String {
|
||||
val digest = MessageDigest.getInstance("SHA-256")
|
||||
digest.update(readBytes())
|
||||
return digest.digest().joinToString(separator = "") { byte -> "%02x".format(byte.toInt() and 0xff) }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
package com.aryan.reader.desktop
|
||||
|
||||
import com.aryan.reader.shared.SharedFeaturePolicy
|
||||
import java.io.File
|
||||
import java.nio.file.Files
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class DesktopBuildProfileTest {
|
||||
@Test
|
||||
fun `standard desktop flavor keeps online features available`() {
|
||||
val profile = desktopBuildProfileForFlavor("standard")
|
||||
|
||||
assertEquals(DesktopFlavorStandard, profile.flavor)
|
||||
assertEquals(EpistemeDesktopStandardAppName, profile.appName)
|
||||
assertEquals("Standard edition", profile.buildLabel)
|
||||
assertEquals(SharedFeaturePolicy.Standard, profile.featurePolicy)
|
||||
assertTrue(profile.featurePolicy.networkAccess)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `oss offline desktop flavor disables network backed features`() {
|
||||
val profile = desktopBuildProfileForFlavor("oss-offline")
|
||||
|
||||
assertEquals(DesktopFlavorOssOffline, profile.flavor)
|
||||
assertEquals(EpistemeDesktopOssAppName, profile.appName)
|
||||
assertEquals("Offline OSS edition", profile.buildLabel)
|
||||
assertEquals(SharedFeaturePolicy.OssOffline, profile.featurePolicy)
|
||||
assertFalse(profile.featurePolicy.networkAccess)
|
||||
assertFalse(profile.featurePolicy.aiAndCloud)
|
||||
assertFalse(profile.featurePolicy.opdsCatalogs)
|
||||
assertFalse(profile.featurePolicy.googleFontsDownload)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `oss desktop flavor aliases resolve to offline oss profile`() {
|
||||
val profile = desktopBuildProfileForFlavor("oss")
|
||||
|
||||
assertEquals(DesktopFlavorOssOffline, profile.flavor)
|
||||
assertEquals(EpistemeDesktopOssAppName, profile.appName)
|
||||
assertEquals(SharedFeaturePolicy.OssOffline, profile.featurePolicy)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `desktop diagnostics are disabled unless explicitly enabled`() {
|
||||
assertFalse(desktopDiagnosticsFlag(null))
|
||||
assertFalse(desktopDiagnosticsFlag(""))
|
||||
assertFalse(desktopDiagnosticsFlag("false"))
|
||||
assertFalse(desktopDiagnosticsFlag("1"))
|
||||
|
||||
assertTrue(desktopDiagnosticsFlag("true"))
|
||||
assertTrue(desktopDiagnosticsFlag(" TRUE "))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `bundled webview detection requires cef binaries`() {
|
||||
val dir = Files.createTempDirectory("episteme-kcef-test").toFile()
|
||||
try {
|
||||
val windowsX64 = DesktopPlatform(DesktopOperatingSystem.WINDOWS, DesktopArchitecture.X64)
|
||||
assertFalse(isBundledDesktopWebViewPresent(dir, windowsX64))
|
||||
File(dir, "jcef.dll").writeText("jcef")
|
||||
File(dir, "libcef.dll").writeText("cef")
|
||||
|
||||
assertTrue(isBundledDesktopWebViewPresent(dir, windowsX64))
|
||||
} finally {
|
||||
dir.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `linux bundled webview detection requires cef shared library and resources`() {
|
||||
val dir = Files.createTempDirectory("episteme-linux-kcef-test").toFile()
|
||||
try {
|
||||
val linuxX64 = DesktopPlatform(DesktopOperatingSystem.LINUX, DesktopArchitecture.X64)
|
||||
assertFalse(isBundledDesktopWebViewPresent(dir, linuxX64))
|
||||
|
||||
File(dir, "libcef.so").writeText("cef")
|
||||
File(dir, "chrome-sandbox").writeText("sandbox")
|
||||
File(dir, "icudtl.dat").writeText("icu")
|
||||
File(dir, "locales").mkdir()
|
||||
|
||||
assertTrue(isBundledDesktopWebViewPresent(dir, linuxX64))
|
||||
} finally {
|
||||
dir.deleteRecursively()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -76,6 +76,21 @@ class DesktopCustomFontStoreTest {
|
|||
assertEquals(listOf("Inter", "Literata"), googleFontsFromJson("""["Inter", "", " Literata "]"""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `download google font fails before network when downloads are disabled`() {
|
||||
val tempRoot = Files.createTempDirectory("episteme-font-store-test").toFile()
|
||||
try {
|
||||
val store = DesktopCustomFontStore(
|
||||
fontsDir = File(tempRoot, "store"),
|
||||
googleFontsDownloadAvailable = { false }
|
||||
)
|
||||
|
||||
assertTrue(store.downloadGoogleFont("Inter").isFailure)
|
||||
} finally {
|
||||
tempRoot.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
private fun File.toFontItem(): CustomFontItem {
|
||||
return CustomFontItem(
|
||||
id = nameWithoutExtension,
|
||||
|
|
|
|||
|
|
@ -24,6 +24,9 @@ class DesktopFolderMetadataExtractorTest {
|
|||
<metadata>
|
||||
<dc:title>Direct EPUB</dc:title>
|
||||
<dc:creator>Ada Lovelace</dc:creator>
|
||||
<dc:description><p>Metadata summary</p></dc:description>
|
||||
<meta name="calibre:series" content="Computing Notes" />
|
||||
<meta name="calibre:series_index" content="2" />
|
||||
<meta name="cover" content="cover-image" />
|
||||
</metadata>
|
||||
<manifest>
|
||||
|
|
@ -42,12 +45,49 @@ class DesktopFolderMetadataExtractorTest {
|
|||
val enriched = result.books.single()
|
||||
assertEquals("Direct EPUB", enriched.title)
|
||||
assertEquals("Ada Lovelace", enriched.author)
|
||||
assertEquals("<p>Metadata summary</p>", enriched.description)
|
||||
assertEquals("Computing Notes", enriched.seriesName)
|
||||
assertEquals(2.0, enriched.seriesIndex)
|
||||
assertEquals("Direct EPUB", enriched.originalTitle)
|
||||
assertEquals("Ada Lovelace", enriched.originalAuthor)
|
||||
assertEquals("Computing Notes", enriched.originalSeriesName)
|
||||
assertEquals(2.0, enriched.originalSeriesIndex)
|
||||
assertEquals("<p>Metadata summary</p>", enriched.originalDescription)
|
||||
assertEquals(epub.lastModified(), enriched.fileContentModifiedTimestamp)
|
||||
assertTrue(enriched.folderTextMetadataParsed)
|
||||
assertTrue(File(assertNotNull(enriched.coverImagePath)).isFile)
|
||||
assertEquals(1, result.stats.updatedBooks)
|
||||
assertEquals(1, result.stats.coversUpdated)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `opened epub gets embedded cover`() = withCoverCacheDir { tempDir ->
|
||||
val epub = File(tempDir, "opened.epub")
|
||||
writeEpub(
|
||||
target = epub,
|
||||
opf = """
|
||||
<package xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||
<metadata>
|
||||
<dc:title>Opened EPUB</dc:title>
|
||||
<dc:creator>Mary Shelley</dc:creator>
|
||||
<meta name="cover" content="cover-image" />
|
||||
</metadata>
|
||||
<manifest>
|
||||
<item id="cover-image" href="images/cover.png" media-type="image/png" />
|
||||
</manifest>
|
||||
</package>
|
||||
""".trimIndent()
|
||||
)
|
||||
val book = bookFor(epub, FileType.EPUB, title = null)
|
||||
|
||||
val enriched = DesktopFolderMetadataExtractor.enrichOpenedBook(book)
|
||||
|
||||
assertEquals("Opened EPUB", enriched.title)
|
||||
assertEquals("Mary Shelley", enriched.author)
|
||||
assertTrue(enriched.folderTextMetadataParsed)
|
||||
assertTrue(File(assertNotNull(enriched.coverImagePath)).isFile)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `direct imported text file gets generated cover`() = withCoverCacheDir { tempDir ->
|
||||
val textFile = File(tempDir, "notes.txt").apply { writeText("Notes") }
|
||||
|
|
|
|||
|
|
@ -0,0 +1,108 @@
|
|||
package com.aryan.reader.desktop
|
||||
|
||||
import com.aryan.reader.shared.BookItem
|
||||
import com.aryan.reader.shared.FileType
|
||||
import com.aryan.reader.shared.LOCAL_FOLDER_SYNC_DATA_DIR
|
||||
import com.aryan.reader.shared.SharedFolderBookMetadata
|
||||
import com.aryan.reader.shared.SharedReaderScreenState
|
||||
import com.aryan.reader.shared.SyncedFolder
|
||||
import java.io.File
|
||||
import java.nio.file.Files
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
|
||||
class DesktopLocalFolderSyncTest {
|
||||
@Test
|
||||
fun `metadata-only sync imports sidecar metadata without scanning physical files`() {
|
||||
val root = Files.createTempDirectory("reader-desktop-folder-sync").toFile()
|
||||
try {
|
||||
File(root, "New.pdf").writeText("%PDF")
|
||||
val existingFile = File(root, "Existing.pdf")
|
||||
val existingId = "local_Existing.pdf"
|
||||
writeMetadataSidecar(
|
||||
root = root,
|
||||
metadata = metadata(
|
||||
id = existingId,
|
||||
title = "Remote Title",
|
||||
progress = 72f,
|
||||
modified = 2_000L
|
||||
)
|
||||
)
|
||||
|
||||
val existingBook = BookItem(
|
||||
id = existingId,
|
||||
path = existingFile.absolutePath,
|
||||
type = FileType.PDF,
|
||||
displayName = existingFile.name,
|
||||
timestamp = 100L,
|
||||
title = "Local Title",
|
||||
progressPercentage = 5f,
|
||||
sourceFolder = root.absolutePath
|
||||
)
|
||||
|
||||
val result = DesktopLocalFolderSync.sync(
|
||||
state = SharedReaderScreenState(
|
||||
rawLibraryBooks = listOf(existingBook),
|
||||
syncedFolders = listOf(syncedFolder(root))
|
||||
),
|
||||
shelfRefs = emptyList(),
|
||||
nowMillis = 3_000L,
|
||||
metadataOnly = true
|
||||
)
|
||||
|
||||
assertEquals(1, result.state.rawLibraryBooks.size)
|
||||
val syncedBook = result.state.rawLibraryBooks.single()
|
||||
assertEquals(existingId, syncedBook.id)
|
||||
assertEquals("Local Title", syncedBook.title)
|
||||
assertEquals(72f, syncedBook.progressPercentage)
|
||||
assertNull(result.state.rawLibraryBooks.firstOrNull { it.id == "local_New.pdf" })
|
||||
assertEquals(0, result.stats.scannedFiles)
|
||||
assertEquals(0, result.stats.newBooks)
|
||||
assertEquals(0, result.stats.removedBooks)
|
||||
assertEquals(1, result.stats.remoteMetadataUpdates)
|
||||
} finally {
|
||||
root.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
private fun syncedFolder(root: File): SyncedFolder {
|
||||
return SyncedFolder(
|
||||
uriString = root.absolutePath,
|
||||
name = root.name,
|
||||
lastScanTime = 0L,
|
||||
allowedFileTypes = setOf(FileType.PDF)
|
||||
)
|
||||
}
|
||||
|
||||
private fun writeMetadataSidecar(root: File, metadata: SharedFolderBookMetadata) {
|
||||
val syncDir = File(root, LOCAL_FOLDER_SYNC_DATA_DIR).apply { mkdirs() }
|
||||
File(syncDir, ".${metadata.bookId}.json").writeText(metadata.toJsonString())
|
||||
}
|
||||
|
||||
private fun metadata(
|
||||
id: String,
|
||||
title: String,
|
||||
progress: Float,
|
||||
modified: Long
|
||||
): SharedFolderBookMetadata {
|
||||
return SharedFolderBookMetadata(
|
||||
bookId = id,
|
||||
title = title,
|
||||
author = null,
|
||||
displayName = "Existing.pdf",
|
||||
type = FileType.PDF.name,
|
||||
lastChapterIndex = null,
|
||||
lastPage = null,
|
||||
lastPositionCfi = null,
|
||||
progressPercentage = progress,
|
||||
isRecent = true,
|
||||
lastModifiedTimestamp = modified,
|
||||
bookmarksJson = null,
|
||||
locatorBlockIndex = null,
|
||||
locatorCharOffset = null,
|
||||
customName = null,
|
||||
highlightsJson = null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import java.io.File
|
|||
import java.nio.file.Files
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFailsWith
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class DesktopOpdsRepositoryTest {
|
||||
|
|
@ -27,6 +28,45 @@ class DesktopOpdsRepositoryTest {
|
|||
assertEquals("pass", custom.password)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `desktop opds http blocks before network in offline flavor`() {
|
||||
withSystemProperty(DesktopFlavorProperty, DesktopFlavorOssOffline) {
|
||||
assertFailsWith<IllegalStateException> {
|
||||
DesktopOpdsHttp.fetchString("https://example.org/opds", null, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `desktop opds http creates basic authorization header for challenged catalogs`() {
|
||||
assertEquals(
|
||||
"Basic dXNlcjpwYXNz",
|
||||
DesktopOpdsHttp.authorizationHeaderForChallenge(
|
||||
challenge = "Basic realm=\"Catalog\"",
|
||||
url = "https://example.org/opds",
|
||||
username = "user",
|
||||
password = "pass"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `desktop opds http creates digest authorization header for challenged catalogs`() {
|
||||
assertEquals(
|
||||
"Digest username=\"Mufasa\", realm=\"testrealm@host.com\", nonce=\"abcdef\", " +
|
||||
"uri=\"/dir/index.atom?x=1\", response=\"ca833912ad1f4339630e23476d538d67\", " +
|
||||
"qop=auth, nc=00000001, cnonce=\"0a4f113b\", opaque=\"xyz\"",
|
||||
DesktopOpdsHttp.authorizationHeaderForChallenge(
|
||||
challenge = "Digest realm=\"testrealm@host.com\", nonce=\"abcdef\", qop=\"auth\", opaque=\"xyz\"",
|
||||
url = "https://example.org/dir/index.atom?x=1",
|
||||
username = "Mufasa",
|
||||
password = "Circle Of Life",
|
||||
cnonce = "0a4f113b",
|
||||
nonceCount = "00000001"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun DesktopOpdsRepository.addCatalogForTest(
|
||||
title: String,
|
||||
url: String,
|
||||
|
|
@ -53,4 +93,26 @@ class DesktopOpdsRepositoryTest {
|
|||
dir.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
private fun withSystemProperty(
|
||||
key: String,
|
||||
value: String?,
|
||||
block: () -> Unit
|
||||
) {
|
||||
val previous = System.getProperty(key)
|
||||
try {
|
||||
if (value == null) {
|
||||
System.clearProperty(key)
|
||||
} else {
|
||||
System.setProperty(key, value)
|
||||
}
|
||||
block()
|
||||
} finally {
|
||||
if (previous == null) {
|
||||
System.clearProperty(key)
|
||||
} else {
|
||||
System.setProperty(key, previous)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,52 @@
|
|||
package com.aryan.reader.desktop
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.aryan.reader.shared.PdfDisplayMode
|
||||
import com.aryan.reader.shared.ReaderTheme
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
class DesktopPdfThemeTest {
|
||||
@Test
|
||||
fun `desktop pdf defaults to vertical display mode`() {
|
||||
assertEquals(PdfDisplayMode.VERTICAL_SCROLL, DesktopDefaultPdfDisplayMode)
|
||||
assertEquals(8.dp, DesktopDefaultPdfVerticalPageGap)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `page background follows android pdf theme defaults`() {
|
||||
val noTheme = ReaderTheme("no_theme", "No Theme", Color.Unspecified, Color.Unspecified, false)
|
||||
val reverse = ReaderTheme("reverse", "Reverse", Color.Black, Color.White, true)
|
||||
val sepia = ReaderTheme("sepia", "Sepia", Color(0xFFFBF0D9), Color(0xFF5F4B32), false)
|
||||
|
||||
assertEquals(Color.White, desktopPdfPageBackgroundColor(noTheme, PdfDisplayMode.VERTICAL_SCROLL))
|
||||
assertEquals(Color.Black, desktopPdfPageBackgroundColor(noTheme, PdfDisplayMode.PAGINATION))
|
||||
assertEquals(Color.Black, desktopPdfPageBackgroundColor(reverse, PdfDisplayMode.VERTICAL_SCROLL))
|
||||
assertEquals(Color.White, desktopPdfPageBackgroundColor(reverse, PdfDisplayMode.PAGINATION))
|
||||
assertEquals(Color(0xFFFBF0D9), desktopPdfPageBackgroundColor(sepia, PdfDisplayMode.VERTICAL_SCROLL))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `vertical viewport uses app gap color only when page gaps are visible`() {
|
||||
val pageBackground = Color.White
|
||||
val gapBackground = Color(0xFFE2E2E2)
|
||||
|
||||
assertEquals(
|
||||
gapBackground,
|
||||
desktopPdfVerticalViewportBackgroundColor(
|
||||
pageBackgroundColor = pageBackground,
|
||||
gapBackgroundColor = gapBackground,
|
||||
isPageGapVisible = true
|
||||
)
|
||||
)
|
||||
assertEquals(
|
||||
pageBackground,
|
||||
desktopPdfVerticalViewportBackgroundColor(
|
||||
pageBackgroundColor = pageBackground,
|
||||
gapBackgroundColor = gapBackground,
|
||||
isPageGapVisible = false
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
package com.aryan.reader.desktop
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
class DesktopPlatformPathsTest {
|
||||
@Test
|
||||
fun `desktop platform detects linux x64 resource names`() {
|
||||
val platform = currentDesktopPlatform(osName = "Linux", osArch = "amd64")
|
||||
|
||||
assertEquals(DesktopOperatingSystem.LINUX, platform.os)
|
||||
assertEquals(DesktopArchitecture.X64, platform.architecture)
|
||||
assertEquals("kcef-bundle-linux-x64", platform.kcefBundleDirectoryName)
|
||||
assertEquals("linux-x64-v8", platform.pdfiumDirectoryName)
|
||||
assertEquals("lib", platform.pdfiumLibraryDirectoryName)
|
||||
assertEquals("libpdfium.so", platform.pdfiumLibraryFileName)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `desktop platform keeps existing windows resource names`() {
|
||||
val platform = currentDesktopPlatform(osName = "Windows 11", osArch = "amd64")
|
||||
|
||||
assertEquals(DesktopOperatingSystem.WINDOWS, platform.os)
|
||||
assertEquals(DesktopArchitecture.X64, platform.architecture)
|
||||
assertEquals("kcef-bundle", platform.kcefBundleDirectoryName)
|
||||
assertEquals("win-x64-v8", platform.pdfiumDirectoryName)
|
||||
assertEquals("bin", platform.pdfiumLibraryDirectoryName)
|
||||
assertEquals("pdfium.dll", platform.pdfiumLibraryFileName)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `linux user directories follow xdg environment variables`() {
|
||||
val platform = DesktopPlatform(DesktopOperatingSystem.LINUX, DesktopArchitecture.X64)
|
||||
val env = mapOf(
|
||||
"XDG_DATA_HOME" to "/tmp/xdg-data",
|
||||
"XDG_CONFIG_HOME" to "/tmp/xdg-config",
|
||||
"XDG_CACHE_HOME" to "/tmp/xdg-cache"
|
||||
)
|
||||
|
||||
assertEquals("/tmp/xdg-data/episteme", desktopUserDataRoot(platform, env::get, "/home/reader").portablePath())
|
||||
assertEquals("/tmp/xdg-config/episteme", desktopUserConfigRoot(platform, env::get, "/home/reader").portablePath())
|
||||
assertEquals("/tmp/xdg-cache/episteme", desktopUserCacheRoot(platform, env::get, "/home/reader").portablePath())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `linux user directories ignore relative xdg environment values`() {
|
||||
val platform = DesktopPlatform(DesktopOperatingSystem.LINUX, DesktopArchitecture.X64)
|
||||
val env = mapOf(
|
||||
"XDG_DATA_HOME" to "relative-data",
|
||||
"XDG_CONFIG_HOME" to "relative-config",
|
||||
"XDG_CACHE_HOME" to "relative-cache"
|
||||
)
|
||||
|
||||
assertEquals("/home/reader/.local/share/episteme", desktopUserDataRoot(platform, env::get, "/home/reader").portablePath())
|
||||
assertEquals("/home/reader/.config/episteme", desktopUserConfigRoot(platform, env::get, "/home/reader").portablePath())
|
||||
assertEquals("/home/reader/.cache/episteme", desktopUserCacheRoot(platform, env::get, "/home/reader").portablePath())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `windows user directories keep appdata compatible root`() {
|
||||
val platform = DesktopPlatform(DesktopOperatingSystem.WINDOWS, DesktopArchitecture.X64)
|
||||
val env = mapOf("APPDATA" to "C:/Users/reader/AppData/Roaming")
|
||||
|
||||
assertEquals("C:/Users/reader/AppData/Roaming/Episteme", desktopUserDataRoot(platform, env::get, "C:/Users/reader").portablePath())
|
||||
assertEquals("C:/Users/reader/AppData/Roaming/Episteme", desktopUserConfigRoot(platform, env::get, "C:/Users/reader").portablePath())
|
||||
assertEquals("C:/Users/reader/AppData/Roaming/Episteme", desktopUserCacheRoot(platform, env::get, "C:/Users/reader").portablePath())
|
||||
}
|
||||
}
|
||||
|
||||
private fun java.io.File.portablePath(): String {
|
||||
return path.replace('\\', '/')
|
||||
}
|
||||
|
|
@ -0,0 +1,182 @@
|
|||
package com.aryan.reader.desktop
|
||||
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import com.aryan.reader.shared.BookItem
|
||||
import com.aryan.reader.shared.FileType
|
||||
import com.aryan.reader.shared.ReaderPlatform
|
||||
import com.aryan.reader.shared.SharedFileCapabilities
|
||||
import com.aryan.reader.shared.pdf.PdfZoomSpec
|
||||
import com.aryan.reader.shared.reader.ReaderReadingMode
|
||||
import com.aryan.reader.shared.reader.ReaderSettings
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class DesktopReaderDefaultsTest {
|
||||
|
||||
@Test
|
||||
fun `desktop open book dialog accepts every shared desktop readable format`() {
|
||||
assertEquals(
|
||||
SharedFileCapabilities.readableTypesFor(ReaderPlatform.DESKTOP),
|
||||
desktopBookFileTypesForDialog()
|
||||
)
|
||||
assertTrue(FileType.PDF in desktopBookFileTypesForDialog())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `desktop uses global reader defaults when book has no local settings`() {
|
||||
val defaults = ReaderSettings(fontSize = 23, readingMode = ReaderReadingMode.VERTICAL)
|
||||
val book = bookItem("without-local")
|
||||
|
||||
assertEquals(defaults, resolvedDesktopReaderSettings(book, defaults))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `desktop keeps local book reader settings ahead of global defaults`() {
|
||||
val defaults = ReaderSettings(fontSize = 23, readingMode = ReaderReadingMode.VERTICAL)
|
||||
val local = ReaderSettings(fontSize = 17, readingMode = ReaderReadingMode.PAGINATED, themeId = "sepia")
|
||||
val book = bookItem("with-local").copy(readerSettings = local)
|
||||
|
||||
assertEquals(local, resolvedDesktopReaderSettings(book, defaults))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `desktop pdf zoom allows deeper page magnification`() {
|
||||
val sharedDefaultMax = PdfZoomSpec().max
|
||||
val letterPageScale = DesktopPdfZoomSpec.safeRenderScale(
|
||||
pageWidth = 612f,
|
||||
pageHeight = 792f,
|
||||
requestedScale = 6f
|
||||
)
|
||||
|
||||
assertEquals(8f, DesktopPdfZoomSpec.max)
|
||||
assertTrue(letterPageScale > sharedDefaultMax)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `desktop pdf touchpad zoom factors zoom in and out`() {
|
||||
val zoomSpec = PdfZoomSpec(min = 0.5f, max = 8f, default = 1f)
|
||||
|
||||
assertTrue(desktopPdfScrollZoomFactor(-1f) > 1.1f)
|
||||
assertTrue(desktopPdfScrollZoomFactor(1f) < 0.9f)
|
||||
assertEquals(8f, desktopPdfZoomTarget(currentZoom = 7.8f, zoomSpec = zoomSpec, factor = 2f))
|
||||
assertEquals(0.5f, desktopPdfZoomTarget(currentZoom = 0.6f, zoomSpec = zoomSpec, factor = 0.1f))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `desktop paginated pdf page changes avoid high resolution first render`() {
|
||||
assertEquals(
|
||||
DesktopPdfPaginationFastFirstRenderMaxScale,
|
||||
desktopPdfPaginationFirstRenderScale(requestedScale = 6f, hasPageRender = false)
|
||||
)
|
||||
assertEquals(
|
||||
6f,
|
||||
desktopPdfPaginationFirstRenderScale(
|
||||
requestedScale = 6f,
|
||||
hasPageRender = false,
|
||||
isOpeningRender = true
|
||||
)
|
||||
)
|
||||
assertEquals(
|
||||
6f,
|
||||
desktopPdfPaginationFirstRenderScale(requestedScale = 6f, hasPageRender = true)
|
||||
)
|
||||
assertEquals(
|
||||
1.25f,
|
||||
desktopPdfPaginationFirstRenderScale(requestedScale = 1.25f, hasPageRender = false)
|
||||
)
|
||||
assertEquals(
|
||||
0.75f,
|
||||
desktopPdfPaginationFirstRenderScale(requestedScale = 0.75f, hasPageRender = false)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `desktop pdf anchored zoom keeps cursor content stable`() {
|
||||
assertEquals(
|
||||
300,
|
||||
desktopPdfAnchoredScrollTarget(currentScroll = 100, anchor = 100f, oldZoom = 1f, newZoom = 2f)
|
||||
)
|
||||
assertEquals(
|
||||
25,
|
||||
desktopPdfAnchoredScrollTarget(currentScroll = 150, anchor = 100f, oldZoom = 2f, newZoom = 1f)
|
||||
)
|
||||
assertEquals(
|
||||
100,
|
||||
desktopPdfAnchoredLazyItemScrollOffset(itemOffset = 0, anchor = 100f, oldZoom = 1f, newZoom = 2f)
|
||||
)
|
||||
assertEquals(
|
||||
200,
|
||||
desktopPdfAnchoredLazyItemScrollOffset(itemOffset = -50, anchor = 100f, oldZoom = 1f, newZoom = 2f)
|
||||
)
|
||||
assertEquals(
|
||||
IntOffset(100, 100),
|
||||
desktopPdfAnchoredPageScrollDelta(
|
||||
viewportRootOffset = Offset.Zero,
|
||||
oldPageRootOffset = Offset.Zero,
|
||||
currentPageRootOffset = Offset.Zero,
|
||||
anchor = Offset(100f, 100f),
|
||||
oldZoom = 1f,
|
||||
newZoom = 2f
|
||||
)
|
||||
)
|
||||
assertEquals(
|
||||
IntOffset(0, 0),
|
||||
desktopPdfAnchoredPageScrollDelta(
|
||||
viewportRootOffset = Offset.Zero,
|
||||
oldPageRootOffset = Offset.Zero,
|
||||
currentPageRootOffset = Offset(-100f, -100f),
|
||||
anchor = Offset(100f, 100f),
|
||||
oldZoom = 1f,
|
||||
newZoom = 2f
|
||||
)
|
||||
)
|
||||
val offCenterPivot = desktopPdfZoomPreviewPivotFraction(
|
||||
viewportRootOffset = Offset(20f, 30f),
|
||||
pageRootOffset = Offset(120f, 230f),
|
||||
anchor = Offset(250f, 450f),
|
||||
pageCanvasSize = IntSize(500, 1000)
|
||||
) ?: error("Expected off-center pivot")
|
||||
assertEquals(0.3f, offCenterPivot.x, 0.0001f)
|
||||
assertEquals(0.25f, offCenterPivot.y, 0.0001f)
|
||||
|
||||
val clampedPivot = desktopPdfZoomPreviewPivotFraction(
|
||||
viewportRootOffset = Offset.Zero,
|
||||
pageRootOffset = Offset.Zero,
|
||||
anchor = Offset(900f, -20f),
|
||||
pageCanvasSize = IntSize(500, 1000)
|
||||
) ?: error("Expected clamped pivot")
|
||||
assertEquals(1f, clampedPivot.x, 0.0001f)
|
||||
assertEquals(0f, clampedPivot.y, 0.0001f)
|
||||
|
||||
val firstPageDocumentTranslation = desktopPdfDocumentZoomPreviewTranslation(
|
||||
viewportRootOffset = Offset.Zero,
|
||||
pageRootOffset = Offset(0f, 0f),
|
||||
anchor = Offset(100f, 200f),
|
||||
previewScale = 2f
|
||||
) ?: error("Expected first page document translation")
|
||||
assertEquals(-100f, firstPageDocumentTranslation.x, 0.0001f)
|
||||
assertEquals(-200f, firstPageDocumentTranslation.y, 0.0001f)
|
||||
|
||||
val secondPageDocumentTranslation = desktopPdfDocumentZoomPreviewTranslation(
|
||||
viewportRootOffset = Offset.Zero,
|
||||
pageRootOffset = Offset(0f, 900f),
|
||||
anchor = Offset(100f, 200f),
|
||||
previewScale = 2f
|
||||
) ?: error("Expected second page document translation")
|
||||
assertEquals(-100f, secondPageDocumentTranslation.x, 0.0001f)
|
||||
assertEquals(700f, secondPageDocumentTranslation.y, 0.0001f)
|
||||
}
|
||||
|
||||
private fun bookItem(id: String): BookItem {
|
||||
return BookItem(
|
||||
id = id,
|
||||
path = "C:/Books/$id.epub",
|
||||
type = FileType.EPUB,
|
||||
displayName = "$id.epub",
|
||||
timestamp = 1L
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
package com.aryan.reader.desktop
|
||||
|
||||
import com.aryan.reader.shared.ReaderFeatureSurface
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class DesktopStartupTest {
|
||||
@Test
|
||||
fun `startup splash uses compact branded feedback`() {
|
||||
val spec = epistemeDesktopStartupSplashSpec(desktopBuildProfileForFlavor("standard"))
|
||||
|
||||
assertEquals(EpistemeDesktopWindowTitle, spec.title)
|
||||
assertTrue(spec.message.isNotBlank())
|
||||
assertTrue(spec.width in 320..480)
|
||||
assertTrue(spec.height in 180..280)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `oss startup splash uses oss branding`() {
|
||||
val spec = epistemeDesktopStartupSplashSpec(desktopBuildProfileForFlavor("oss-offline"))
|
||||
|
||||
assertEquals(EpistemeDesktopOssAppName, spec.title)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `embedded webview starts only for epub backed reader surfaces`() {
|
||||
assertTrue(shouldRequestDesktopWebViewRuntime(ReaderFeatureSurface.EPUB_READER))
|
||||
assertTrue(shouldRequestDesktopWebViewRuntime(ReaderFeatureSurface.TEXT_READER))
|
||||
assertFalse(shouldRequestDesktopWebViewRuntime(ReaderFeatureSurface.PDF_VIEWER))
|
||||
assertFalse(shouldRequestDesktopWebViewRuntime(null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `embedded webview startup skips terminal runtime states`() {
|
||||
assertFalse(shouldStartDesktopWebViewRuntime(requested = false, state = DesktopWebViewRuntimeState()))
|
||||
assertTrue(shouldStartDesktopWebViewRuntime(requested = true, state = DesktopWebViewRuntimeState()))
|
||||
assertFalse(
|
||||
shouldStartDesktopWebViewRuntime(
|
||||
requested = true,
|
||||
state = DesktopWebViewRuntimeState(initialized = true)
|
||||
)
|
||||
)
|
||||
assertFalse(
|
||||
shouldStartDesktopWebViewRuntime(
|
||||
requested = true,
|
||||
state = DesktopWebViewRuntimeState(restartRequired = true)
|
||||
)
|
||||
)
|
||||
assertFalse(
|
||||
shouldStartDesktopWebViewRuntime(
|
||||
requested = true,
|
||||
state = DesktopWebViewRuntimeState(errorMessage = "missing bundle")
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
package com.aryan.reader.desktop
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class DesktopWindowPolishTest {
|
||||
@Test
|
||||
fun `desktop window defaults use app branding and a useful first launch size`() {
|
||||
val defaults = epistemeDesktopWindowDefaults(desktopBuildProfileForFlavor("standard"))
|
||||
|
||||
assertEquals(EpistemeDesktopWindowTitle, defaults.title)
|
||||
assertEquals(EpistemeDesktopWindowIconResource, defaults.iconResourcePath)
|
||||
assertTrue(defaults.defaultSize.width.value > defaults.minimumSize.width.toFloat())
|
||||
assertTrue(defaults.defaultSize.height.value > defaults.minimumSize.height.toFloat())
|
||||
assertEquals(EpistemeDesktopWindowMinimumWidthPx, defaults.minimumSize.width)
|
||||
assertEquals(EpistemeDesktopWindowMinimumHeightPx, defaults.minimumSize.height)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `oss desktop window defaults use oss branding`() {
|
||||
val defaults = epistemeDesktopWindowDefaults(desktopBuildProfileForFlavor("oss-offline"))
|
||||
|
||||
assertEquals(EpistemeDesktopOssAppName, defaults.title)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `desktop chrome colors choose dark mode from dark theme surfaces`() {
|
||||
val darkChrome = desktopWindowChromeColors(
|
||||
captionColor = Color(0xFF12140E),
|
||||
textColor = Color(0xFFE2E3D8),
|
||||
borderColor = Color(0xFF0C0F09)
|
||||
)
|
||||
|
||||
val lightChrome = desktopWindowChromeColors(
|
||||
captionColor = Color(0xFFF9FAEF),
|
||||
textColor = Color(0xFF1A1C16),
|
||||
borderColor = Color(0xFFFFFFFF)
|
||||
)
|
||||
|
||||
assertTrue(darkChrome.useDarkMode)
|
||||
assertFalse(lightChrome.useDarkMode)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
package com.aryan.reader.desktop
|
||||
|
||||
import kotlin.io.path.createTempFile
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
class DesktopWindowStateStoreTest {
|
||||
|
||||
@Test
|
||||
fun `desktop window state round trips through config store`() {
|
||||
val file = createTempFile("episteme-window-state", ".json").toFile()
|
||||
val store = DesktopWindowStateStore(file)
|
||||
val snapshot = DesktopWindowStateSnapshot(
|
||||
placement = DesktopSavedWindowPlacement.FLOATING,
|
||||
widthDp = 1440f,
|
||||
heightDp = 900f,
|
||||
xDp = 120f,
|
||||
yDp = 80f
|
||||
)
|
||||
|
||||
store.save(snapshot)
|
||||
|
||||
assertEquals(snapshot, store.load())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `desktop window state clamps too small saved bounds`() {
|
||||
val snapshot = DesktopWindowStateSnapshot(
|
||||
placement = DesktopSavedWindowPlacement.FLOATING,
|
||||
widthDp = 12f,
|
||||
heightDp = 34f
|
||||
).sanitized()
|
||||
|
||||
assertEquals(EpistemeDesktopWindowMinimumWidthPx.toFloat(), snapshot.widthDp)
|
||||
assertEquals(EpistemeDesktopWindowMinimumHeightPx.toFloat(), snapshot.heightDp)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue