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:
Aryan 2026-06-14 13:43:49 +05:30 committed by GitHub
parent a13d6599d1
commit 625a4d5d2e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
102 changed files with 6012 additions and 687 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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