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:
Aryan 2026-05-15 22:36:51 +05:30 committed by GitHub
parent c0d0e57e79
commit b20ade9946
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
247 changed files with 43321 additions and 7087 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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("&nbsp;", " ")
.replace("&amp;", "&")
@ -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
)

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,6 @@
package com.aryan.reader.desktop
fun main() {
val startupSplash = DesktopStartupSplash.show()
launchEpistemeDesktopApplication(startupSplash)
}