Linux support (#381)
* Add desktop release CI and support for Arch Linux packaging * Make Gradle wrapper executable in desktop-release workflow * Make Gradle wrapper executable in desktop-release workflow * Make Gradle wrapper executable in desktop-release workflow * Configure Gradle and update Java environment in desktop-release workflow * Update Java setup and AUR packaging in desktop release workflow * Update Java setup and AUR packaging in desktop release workflow * Add MSIX packaging support for Windows desktop distribution * Update AUR packaging metadata and validation * Use spine toc attribute for NCX resolution * crash fixes * Implement automatic discovery and injection of EPUB font face siblings * Enhance custom font support with family grouping and variable font handling * Optimize metadata loading and improve TTS highlighting * Add keyboard navigation support for EPUB reader * Refine PDF spread page sizing to respect aspect ratios * Implement responsive maximum height for reader popups and sheets * Handle TTS generation failures by skipping problematic chunks * Refactor PDF tile rendering logic and zoom indicator behavior * Prefer block and offset locators over page index in native vertical flow * Implement save and share actions for original book files * Add Estonian language support * Implement temporary viewing mode for external files * Implement direct opening for temporary external files without library persistence * fix failing tests * Import SharedFileCapabilities in DesktopLibraryUi * Improve native vertical reader progress, persistence, and image support * Center target in viewport for native vertical reader and support animated scrolling
This commit is contained in:
parent
a13d6599d1
commit
625a4d5d2e
102 changed files with 6012 additions and 687 deletions
|
|
@ -77,6 +77,16 @@ internal fun chooseSaveImageFile(defaultFileName: String): File? {
|
|||
return File(directory, file)
|
||||
}
|
||||
|
||||
internal fun chooseSaveBookFile(defaultFileName: String): File? {
|
||||
val dialog = FileDialog(null as Frame?, desktopDialogString("action_save_copy_to_device", "Save copy to device"), FileDialog.SAVE).apply {
|
||||
file = defaultFileName
|
||||
isVisible = true
|
||||
}
|
||||
val directory = dialog.directory ?: return null
|
||||
val file = dialog.file ?: return null
|
||||
return File(directory, file)
|
||||
}
|
||||
|
||||
internal fun chooseFolder(): File? {
|
||||
val chooser = JFileChooser().apply {
|
||||
dialogTitle = desktopDialogString("desktop_import_folder", "Import folder")
|
||||
|
|
|
|||
|
|
@ -58,7 +58,8 @@ internal val DesktopLanguageOptions = listOf(
|
|||
DesktopLanguageOption("zh-CN", "language_chinese_simplified", "Chinese, Simplified"),
|
||||
DesktopLanguageOption("nl", "language_dutch", "Dutch"),
|
||||
DesktopLanguageOption("uk", "language_ukrainian", "Ukrainian"),
|
||||
DesktopLanguageOption("id", "language_indonesian", "Indonesian")
|
||||
DesktopLanguageOption("id", "language_indonesian", "Indonesian"),
|
||||
DesktopLanguageOption("et", "language_estonian", "Estonian")
|
||||
)
|
||||
|
||||
internal fun selectedDesktopLanguageOption(languageTag: String?): DesktopLanguageOption {
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ import com.aryan.reader.shared.AppAction
|
|||
import com.aryan.reader.shared.BannerMessage
|
||||
import com.aryan.reader.shared.BookItem
|
||||
import com.aryan.reader.shared.ReaderPlatform
|
||||
import com.aryan.reader.shared.SharedFileCapabilities
|
||||
import com.aryan.reader.shared.SharedFolderPathResolver
|
||||
import com.aryan.reader.shared.SharedReaderScreenState
|
||||
import com.aryan.reader.shared.Shelf
|
||||
|
|
@ -63,6 +64,24 @@ internal fun String.toDesktopSafeFileName(): String {
|
|||
return replace(Regex("[^A-Za-z0-9._-]"), "_").take(120).ifBlank { "book" }
|
||||
}
|
||||
|
||||
internal fun BookItem.desktopSuggestedOriginalFileName(): String {
|
||||
val extension = path
|
||||
?.let(::File)
|
||||
?.extension
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?: SharedFileCapabilities.primaryExtensionFor(type)
|
||||
val safeName = displayName
|
||||
.takeIf { it.isNotBlank() }
|
||||
?: title?.takeIf { it.isNotBlank() }
|
||||
?: "book"
|
||||
val sanitized = safeName.toDesktopSafeFileName()
|
||||
return if (extension != null && !sanitized.endsWith(".$extension", ignoreCase = true)) {
|
||||
"$sanitized.$extension"
|
||||
} else {
|
||||
sanitized
|
||||
}
|
||||
}
|
||||
|
||||
internal fun BookItem.withDesktopImportMetadata(
|
||||
enriched: BookItem,
|
||||
original: BookItem?
|
||||
|
|
@ -203,7 +222,8 @@ internal fun LibraryScreen(
|
|||
onImportFolder: () -> Unit,
|
||||
onSyncFolderMetadata: () -> Unit,
|
||||
onScanFolders: () -> Unit,
|
||||
onTogglePinned: (BookItem) -> Unit
|
||||
onTogglePinned: (BookItem) -> Unit,
|
||||
onSaveOriginalFile: (BookItem) -> Unit = {}
|
||||
) {
|
||||
SharedLibraryScreen(
|
||||
state = state,
|
||||
|
|
@ -231,6 +251,7 @@ internal fun LibraryScreen(
|
|||
onSyncFolderMetadata = onSyncFolderMetadata,
|
||||
onScanFolders = onScanFolders,
|
||||
onTogglePinned = onTogglePinned,
|
||||
onSaveOriginalFile = onSaveOriginalFile,
|
||||
platform = ReaderPlatform.DESKTOP,
|
||||
useImportEmptyStateWhenLibraryEmpty = true
|
||||
)
|
||||
|
|
|
|||
|
|
@ -7,11 +7,44 @@ import com.aryan.reader.shared.AppFontPreferenceKind
|
|||
import com.aryan.reader.shared.CustomFontItem
|
||||
import com.aryan.reader.shared.reader.ReaderPage
|
||||
import com.aryan.reader.shared.reader.ReaderSettings
|
||||
import com.aryan.reader.shared.detectFontVariant
|
||||
import com.aryan.reader.shared.familyFilenameSignature
|
||||
import com.aryan.reader.shared.supportsVariableWeightAxis
|
||||
import java.io.File
|
||||
|
||||
internal fun ReaderSettings.toDesktopReaderFontFamily(): FontFamily {
|
||||
customFontPath?.takeIf { it.isNotBlank() }?.let { path ->
|
||||
runCatching { FontFamily(DesktopFont(File(path))) }.getOrNull()?.let { return it }
|
||||
val baseFile = File(path)
|
||||
val signature = baseFile.nameWithoutExtension.familyFilenameSignature()
|
||||
val siblings = baseFile.parentFile?.listFiles()?.filter {
|
||||
it.isFile && it.extension.lowercase() in setOf("ttf", "otf", "woff", "woff2") &&
|
||||
it.nameWithoutExtension.familyFilenameSignature() == signature
|
||||
} ?: listOf(baseFile)
|
||||
|
||||
val seenVariants = mutableSetOf<String>()
|
||||
val fontList = siblings.flatMap { sibling ->
|
||||
try {
|
||||
val variant = sibling.nameWithoutExtension.detectFontVariant()
|
||||
val weights = if (sibling.nameWithoutExtension.supportsVariableWeightAxis()) {
|
||||
variableDesktopReaderFontWeights
|
||||
} else {
|
||||
listOf(variant?.weight ?: androidx.compose.ui.text.font.FontWeight.Normal)
|
||||
}
|
||||
weights.mapNotNull { weight ->
|
||||
val style = variant?.style ?: androidx.compose.ui.text.font.FontStyle.Normal
|
||||
if (seenVariants.add("${weight.weight}|$style")) {
|
||||
DesktopFont(sibling, weight, style)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
if (fontList.isNotEmpty()) {
|
||||
return FontFamily(fontList)
|
||||
}
|
||||
}
|
||||
return fontFamily.toComposeFontFamily()
|
||||
}
|
||||
|
|
@ -25,6 +58,18 @@ private fun String.toComposeFontFamily(): FontFamily {
|
|||
}
|
||||
}
|
||||
|
||||
private val variableDesktopReaderFontWeights = listOf(
|
||||
androidx.compose.ui.text.font.FontWeight.Thin,
|
||||
androidx.compose.ui.text.font.FontWeight.ExtraLight,
|
||||
androidx.compose.ui.text.font.FontWeight.Light,
|
||||
androidx.compose.ui.text.font.FontWeight.Normal,
|
||||
androidx.compose.ui.text.font.FontWeight.Medium,
|
||||
androidx.compose.ui.text.font.FontWeight.SemiBold,
|
||||
androidx.compose.ui.text.font.FontWeight.Bold,
|
||||
androidx.compose.ui.text.font.FontWeight.ExtraBold,
|
||||
androidx.compose.ui.text.font.FontWeight.Black
|
||||
)
|
||||
|
||||
internal fun List<ReaderPage>.samePageLayoutAs(other: List<ReaderPage>): Boolean {
|
||||
if (size != other.size) return false
|
||||
return indices.all { index ->
|
||||
|
|
|
|||
|
|
@ -638,6 +638,23 @@ internal fun EpistemeDesktopApp(
|
|||
}
|
||||
}
|
||||
|
||||
fun saveDesktopOriginalFile(book: BookItem) {
|
||||
val source = book.path?.let(::File)
|
||||
if (source?.isFile != true) {
|
||||
updateState(state.withBanner("Original file is not available.", isError = true))
|
||||
return
|
||||
}
|
||||
val target = chooseSaveBookFile(book.desktopSuggestedOriginalFileName()) ?: return
|
||||
runCatching {
|
||||
target.parentFile?.mkdirs()
|
||||
source.copyTo(target, overwrite = true)
|
||||
}.onSuccess {
|
||||
updateState(state.withBanner("Saved ${target.name}."))
|
||||
}.onFailure { error ->
|
||||
updateState(state.withBanner(error.message ?: "Could not save file.", isError = true))
|
||||
}
|
||||
}
|
||||
|
||||
fun clearDesktopBookCache() {
|
||||
scope.launch {
|
||||
withContext(Dispatchers.IO) {
|
||||
|
|
@ -4200,7 +4217,8 @@ internal fun EpistemeDesktopApp(
|
|||
onManageShelfBooks = { shelfToManageBooks = it },
|
||||
onSyncFolderMetadata = { syncFolderMetadata() },
|
||||
onScanFolders = { scanSyncedFolders() },
|
||||
onTogglePinned = { book -> updateState(state.reduce(AppAction.LibraryPinToggled(book.id))) }
|
||||
onTogglePinned = { book -> updateState(state.reduce(AppAction.LibraryPinToggled(book.id))) },
|
||||
onSaveOriginalFile = ::saveDesktopOriginalFile
|
||||
)
|
||||
|
||||
SharedAppTab.SHELVES -> LibraryScreen(
|
||||
|
|
@ -4249,7 +4267,8 @@ internal fun EpistemeDesktopApp(
|
|||
onManageShelfBooks = { shelfToManageBooks = it },
|
||||
onSyncFolderMetadata = { syncFolderMetadata() },
|
||||
onScanFolders = { scanSyncedFolders() },
|
||||
onTogglePinned = { book -> updateState(state.reduce(AppAction.LibraryPinToggled(book.id))) }
|
||||
onTogglePinned = { book -> updateState(state.reduce(AppAction.LibraryPinToggled(book.id))) },
|
||||
onSaveOriginalFile = ::saveDesktopOriginalFile
|
||||
)
|
||||
|
||||
SharedAppTab.CATALOGS -> {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,32 @@
|
|||
package com.aryan.reader.desktop
|
||||
|
||||
import java.io.File
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class DesktopAurPackagingMetadataTest {
|
||||
@Test
|
||||
fun `aur metadata declares arch runtime dependencies license and desktop mime support`() {
|
||||
val buildScript = desktopBuildScriptText()
|
||||
|
||||
assertTrue(buildScript.contains("\"libarchive\""))
|
||||
assertTrue(buildScript.contains("license=('AGPL-3.0-only')"))
|
||||
assertTrue(buildScript.contains("license = AGPL-3.0-only"))
|
||||
assertTrue(buildScript.contains("/usr/share/licenses/${'$'}pkgname/LICENSE"))
|
||||
assertTrue(buildScript.contains("application/epub+zip"))
|
||||
assertTrue(buildScript.contains("application/vnd.comicbook+zip"))
|
||||
assertTrue(buildScript.contains("application/vnd.openxmlformats-officedocument.wordprocessingml.document"))
|
||||
}
|
||||
|
||||
private fun desktopBuildScriptText(): String {
|
||||
val candidates = listOf(
|
||||
File("build.gradle.kts"),
|
||||
File("desktopApp/build.gradle.kts")
|
||||
)
|
||||
val buildFile = candidates.firstOrNull { file ->
|
||||
file.isFile && file.readText().contains("PrepareDesktopAurPackageTask")
|
||||
}
|
||||
requireNotNull(buildFile) { "Could not locate desktopApp/build.gradle.kts" }
|
||||
return buildFile.readText()
|
||||
}
|
||||
}
|
||||
|
|
@ -127,6 +127,14 @@ class DesktopStringResourcesTest {
|
|||
assertEquals("language_portuguese_brazilian", option.labelKey)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resolvesSelectedDesktopLanguageOptionForEstonian() {
|
||||
val option = selectedDesktopLanguageOption("et")
|
||||
|
||||
assertEquals("et", option.normalizedTag)
|
||||
assertEquals("language_estonian", option.labelKey)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun desktopLanguageSettingsStorePersistsLanguageAcrossInstances() {
|
||||
val tempDirectory = Files.createTempDirectory("episteme-desktop-language-test")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue