Desktop update (#362)

* Update documentation images

* Introduce native vertical EPUB reader for Desktop

* Add libsecret support for Linux secret storage

* Update localized strings and plurals across multiple languages

* Update PDF annotation UI and highlighter settings on desktop
This commit is contained in:
Aryan 2026-06-03 09:45:50 +05:30 committed by GitHub
parent e4634ed251
commit 6651169ce2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
60 changed files with 14457 additions and 468 deletions

View file

@ -3,6 +3,7 @@ package com.aryan.reader.desktop
import com.aryan.reader.shared.DEFAULT_CLOUD_TTS_SPEAKER_ID
import com.aryan.reader.shared.GEMINI_CLOUD_TTS_MODEL_ID
import com.aryan.reader.shared.ReaderAiByokSettings
import com.sun.jna.Library
import com.sun.jna.Memory
import com.sun.jna.Native
import com.sun.jna.Pointer
@ -18,6 +19,15 @@ import java.util.concurrent.TimeUnit
private const val WINDOWS_CRED_TYPE_GENERIC = 1
private const val WINDOWS_CRED_PERSIST_LOCAL_MACHINE = 2
private const val WINDOWS_ERROR_NOT_FOUND = 1168
private const val LINUX_SECRET_SCHEMA_DONT_MATCH_NAME = 2
private const val LINUX_SECRET_SCHEMA_ATTRIBUTE_STRING = 0
private const val LINUX_SECRET_SCHEMA_NAME = "com.aryan.reader.Secret"
private const val LINUX_SECRET_COLLECTION_DEFAULT = "default"
private const val LINUX_SECRET_SERVICE_APPLICATION_ATTRIBUTE = "application"
private const val LINUX_SECRET_SERVICE_APPLICATION_VALUE = "Episteme.Reader"
private const val LINUX_SECRET_SERVICE_KEY_ATTRIBUTE = "key"
private const val LINUX_LIBSECRET_PREFIX = "linux-libsecret:"
private const val LINUX_SECRET_TOOL_PREFIX = "secret-tool:"
internal class DesktopAiByokStore(
private val settingsFile: File = defaultSettingsFile(),
@ -174,7 +184,7 @@ internal interface DesktopSecretCodec {
val osName = System.getProperty("os.name").orEmpty()
val codec = when {
osName.startsWith("Windows", ignoreCase = true) -> WindowsSecretCodec
osName.contains("Linux", ignoreCase = true) -> LinuxSecretToolCodec()
osName.contains("Linux", ignoreCase = true) -> LinuxSecretServiceCodec()
else -> UnavailableDesktopSecretCodec
}
logDesktopTts("settings_platform os=\"${osName.desktopTtsPreview()}\" codec=${codec.name}")
@ -241,6 +251,329 @@ private object DesktopProcessSecretCommandRunner : DesktopSecretCommandRunner {
}
}
internal class LinuxSecretServiceCodec(
private val libsecretCodec: DesktopSecretCodec = LinuxLibsecretCodec(),
private val secretToolCodec: DesktopSecretCodec = LinuxSecretToolCodec()
) : DesktopSecretCodec {
private val codecs: List<DesktopSecretCodec> = listOf(libsecretCodec, secretToolCodec)
private val selectedCodec: DesktopSecretCodec? by lazy {
codecs.firstOrNull { codec ->
runCatching { codec.isAvailable }
.onFailure { error ->
logDesktopTts("settings_linux_secret_service_probe_failed codec=${codec.name} error=\"${error.desktopTtsSummary()}\"")
}
.getOrDefault(false)
}
}
override val name: String = "linux-secret-service"
override val isAvailable: Boolean
get() = selectedCodec != null
override fun protect(value: String): String {
return protect("secret", value)
}
override fun unprotect(value: String): String {
return unprotect("secret", value)
}
override fun protect(keyName: String, value: String): String {
val codec = selectedCodec ?: throw IllegalStateException(
"Linux Secret Service is unavailable. Install gnome-keyring or another Secret Service provider."
)
return codec.protect(keyName, value)
}
override fun unprotect(keyName: String, value: String): String {
val orderedCodecs = when {
value.startsWith(LINUX_LIBSECRET_PREFIX) -> listOf(libsecretCodec, secretToolCodec)
value.startsWith(LINUX_SECRET_TOOL_PREFIX) -> listOf(libsecretCodec, secretToolCodec)
else -> codecs
}
for (codec in orderedCodecs) {
val secret = runCatching {
if (!codec.isAvailable) "" else codec.unprotect(keyName, value)
}.onFailure { error ->
logDesktopTts(
"settings_linux_secret_service_read_failed codec=${codec.name} key=$keyName " +
"error=\"${error.desktopTtsSummary()}\""
)
}.getOrDefault("")
if (secret.isNotBlank()) return secret
}
return ""
}
override fun delete(keyName: String) {
codecs.forEach { codec ->
runCatching { codec.delete(keyName) }
.onFailure { error ->
logDesktopTts(
"settings_linux_secret_service_delete_failed codec=${codec.name} key=$keyName " +
"error=\"${error.desktopTtsSummary()}\""
)
}
}
}
}
internal interface LinuxSecretServiceClient {
val isAvailable: Boolean
fun store(key: String, label: String, password: String)
fun lookup(key: String): String?
fun clear(key: String)
}
internal class LinuxLibsecretCodec(
private val client: LinuxSecretServiceClient = JnaLinuxSecretServiceClient
) : DesktopSecretCodec {
override val name: String = "linux-libsecret"
override val isAvailable: Boolean by lazy {
val available = if (!client.isAvailable) {
false
} else {
val probeKey = linuxSecretKey("probe")
val probeSecret = "episteme-linux-libsecret-probe"
runCatching {
client.store(probeKey, "Episteme secure storage probe", probeSecret)
client.lookup(probeKey) == probeSecret
}.onFailure { error ->
logDesktopTts("settings_linux_libsecret_unavailable error=\"${error.desktopTtsSummary()}\"")
}.also {
runCatching { client.clear(probeKey) }
}.getOrDefault(false)
}
logDesktopTts("settings_linux_libsecret_available available=$available")
available
}
override fun protect(value: String): String {
return protect("secret", value)
}
override fun unprotect(value: String): String {
return unprotect("secret", value)
}
override fun protect(keyName: String, value: String): String {
if (!isAvailable) {
throw IllegalStateException(
"Linux Secret Service is unavailable. Install gnome-keyring or another Secret Service provider."
)
}
val key = linuxSecretKey(keyName)
logDesktopTts("settings_linux_libsecret_write_start key=$keyName valueChars=${value.length}")
client.store(key, "Episteme $keyName", value)
logDesktopTts("settings_linux_libsecret_write_result key=$keyName")
return LINUX_LIBSECRET_PREFIX + key
}
override fun unprotect(keyName: String, value: String): String {
if (!isAvailable) return ""
val key = linuxSecretReferenceKey(keyName, value)
logDesktopTts("settings_linux_libsecret_read_start key=$keyName")
val secret = client.lookup(key).orEmpty()
logDesktopTts("settings_linux_libsecret_read_result key=$keyName chars=${secret.length}")
return secret
}
override fun delete(keyName: String) {
if (!client.isAvailable) return
val key = linuxSecretKey(keyName)
runCatching { client.clear(key) }
.onFailure { error ->
logDesktopTts("settings_linux_libsecret_delete_failed key=$keyName error=\"${error.desktopTtsSummary()}\"")
}
}
}
private object JnaLinuxSecretServiceClient : LinuxSecretServiceClient {
override val isAvailable: Boolean by lazy {
runCatching {
LinuxLibsecretNative.INSTANCE
LinuxGlibNative.INSTANCE
true
}.onFailure { error ->
logDesktopTts("settings_linux_libsecret_load_failed error=\"${error.desktopTtsSummary()}\"")
}.getOrDefault(false)
}
private val schema: Pointer by lazy {
LinuxLibsecretNative.INSTANCE.secret_schema_new(
LINUX_SECRET_SCHEMA_NAME,
LINUX_SECRET_SCHEMA_DONT_MATCH_NAME,
LINUX_SECRET_SERVICE_APPLICATION_ATTRIBUTE,
LINUX_SECRET_SCHEMA_ATTRIBUTE_STRING,
LINUX_SECRET_SERVICE_KEY_ATTRIBUTE,
LINUX_SECRET_SCHEMA_ATTRIBUTE_STRING,
null
) ?: throw IllegalStateException("Linux Secret Service schema creation failed.")
}
override fun store(key: String, label: String, password: String) {
val error = PointerByReference()
val stored = LinuxLibsecretNative.INSTANCE.secret_password_store_sync(
schema,
LINUX_SECRET_COLLECTION_DEFAULT,
label,
password,
null,
error,
LINUX_SECRET_SERVICE_APPLICATION_ATTRIBUTE,
LINUX_SECRET_SERVICE_APPLICATION_VALUE,
LINUX_SECRET_SERVICE_KEY_ATTRIBUTE,
key,
null
)
takeLibsecretError(error)?.let { message ->
throw IllegalStateException("Linux Secret Service write failed: $message")
}
if (!stored) {
throw IllegalStateException("Linux Secret Service write failed: libsecret returned false.")
}
}
override fun lookup(key: String): String? {
val error = PointerByReference()
val passwordPointer = LinuxLibsecretNative.INSTANCE.secret_password_lookup_sync(
schema,
null,
error,
LINUX_SECRET_SERVICE_APPLICATION_ATTRIBUTE,
LINUX_SECRET_SERVICE_APPLICATION_VALUE,
LINUX_SECRET_SERVICE_KEY_ATTRIBUTE,
key,
null
)
val errorMessage = takeLibsecretError(error)
if (errorMessage != null) {
passwordPointer?.let { pointer -> LinuxLibsecretNative.INSTANCE.secret_password_free(pointer) }
throw IllegalStateException("Linux Secret Service read failed: $errorMessage")
}
return passwordPointer?.let { pointer ->
try {
pointer.getString(0, Charsets.UTF_8.name())
} finally {
LinuxLibsecretNative.INSTANCE.secret_password_free(pointer)
}
}
}
override fun clear(key: String) {
val error = PointerByReference()
LinuxLibsecretNative.INSTANCE.secret_password_clear_sync(
schema,
null,
error,
LINUX_SECRET_SERVICE_APPLICATION_ATTRIBUTE,
LINUX_SECRET_SERVICE_APPLICATION_VALUE,
LINUX_SECRET_SERVICE_KEY_ATTRIBUTE,
key,
null
)
takeLibsecretError(error)?.let { message ->
throw IllegalStateException("Linux Secret Service delete failed: $message")
}
}
private fun takeLibsecretError(error: PointerByReference): String? {
val errorPointer = error.value ?: return null
return try {
LinuxGError(errorPointer).message
?.getString(0, Charsets.UTF_8.name())
?.ifBlank { null }
?: "unknown libsecret error"
} finally {
LinuxGlibNative.INSTANCE.g_error_free(errorPointer)
}
}
}
private interface LinuxLibsecretNative : Library {
fun secret_schema_new(name: String, flags: Int, vararg attributes: Any?): Pointer?
fun secret_password_store_sync(
schema: Pointer,
collection: String,
label: String,
password: String,
cancellable: Pointer?,
error: PointerByReference,
vararg attributes: Any?
): Boolean
fun secret_password_lookup_sync(
schema: Pointer,
cancellable: Pointer?,
error: PointerByReference,
vararg attributes: Any?
): Pointer?
fun secret_password_clear_sync(
schema: Pointer,
cancellable: Pointer?,
error: PointerByReference,
vararg attributes: Any?
): Boolean
fun secret_password_free(password: Pointer?)
companion object {
val INSTANCE: LinuxLibsecretNative by lazy {
loadLinuxNativeLibrary(
LinuxLibsecretNative::class.java,
"secret-1",
"libsecret-1.so.0"
)
}
}
}
private interface LinuxGlibNative : Library {
fun g_error_free(error: Pointer?)
companion object {
val INSTANCE: LinuxGlibNative by lazy {
loadLinuxNativeLibrary(
LinuxGlibNative::class.java,
"glib-2.0",
"libglib-2.0.so.0"
)
}
}
}
@Structure.FieldOrder("domain", "code", "message")
internal class LinuxGError(pointer: Pointer) : Structure(pointer) {
@JvmField
var domain: Int = 0
@JvmField
var code: Int = 0
@JvmField
var message: Pointer? = null
init {
read()
}
}
private fun <T : Library> loadLinuxNativeLibrary(type: Class<T>, vararg names: String): T {
var lastError: Throwable? = null
for (name in names) {
val loaded = runCatching { Native.load(name, type) as T }
.onFailure { error -> lastError = error }
.getOrNull()
if (loaded != null) return loaded
}
throw IllegalStateException("Could not load Linux native library ${names.joinToString(" or ")}.", lastError)
}
internal class LinuxSecretToolCodec(
private val commandRunner: DesktopSecretCommandRunner = DesktopProcessSecretCommandRunner
) : DesktopSecretCodec {
@ -277,9 +610,9 @@ internal class LinuxSecretToolCodec(
"store",
"--label",
"Episteme $keyName",
SecretToolApplicationAttribute,
SecretToolApplicationValue,
SecretToolKeyAttribute,
LINUX_SECRET_SERVICE_APPLICATION_ATTRIBUTE,
LINUX_SECRET_SERVICE_APPLICATION_VALUE,
LINUX_SECRET_SERVICE_KEY_ATTRIBUTE,
key
),
input = value,
@ -289,20 +622,20 @@ internal class LinuxSecretToolCodec(
if (!result.isSuccess) {
throw IllegalStateException("Linux Secret Service write failed: ${result.errorSummary}")
}
return Prefix + key
return LINUX_SECRET_TOOL_PREFIX + key
}
override fun unprotect(keyName: String, value: String): String {
if (!isAvailable) return ""
val key = value.removePrefix(Prefix).takeIf { value.startsWith(Prefix) } ?: linuxSecretKey(keyName)
val key = linuxSecretReferenceKey(keyName, value)
logDesktopTts("settings_linux_secret_tool_read_start key=$keyName")
val result = commandRunner.run(
command = listOf(
SecretToolCommand,
"lookup",
SecretToolApplicationAttribute,
SecretToolApplicationValue,
SecretToolKeyAttribute,
LINUX_SECRET_SERVICE_APPLICATION_ATTRIBUTE,
LINUX_SECRET_SERVICE_APPLICATION_VALUE,
LINUX_SECRET_SERVICE_KEY_ATTRIBUTE,
key
),
timeoutMillis = 8_000L
@ -321,9 +654,9 @@ internal class LinuxSecretToolCodec(
command = listOf(
SecretToolCommand,
"clear",
SecretToolApplicationAttribute,
SecretToolApplicationValue,
SecretToolKeyAttribute,
LINUX_SECRET_SERVICE_APPLICATION_ATTRIBUTE,
LINUX_SECRET_SERVICE_APPLICATION_VALUE,
LINUX_SECRET_SERVICE_KEY_ATTRIBUTE,
key
),
timeoutMillis = 8_000L
@ -333,19 +666,23 @@ internal class LinuxSecretToolCodec(
}
}
private fun linuxSecretKey(keyName: String): String {
return "Episteme.Reader.$keyName"
}
private companion object {
const val Prefix = "secret-tool:"
const val SecretToolCommand = "secret-tool"
const val SecretToolApplicationAttribute = "application"
const val SecretToolApplicationValue = "Episteme.Reader"
const val SecretToolKeyAttribute = "key"
}
}
private fun linuxSecretKey(keyName: String): String {
return "Episteme.Reader.$keyName"
}
private fun linuxSecretReferenceKey(keyName: String, reference: String): String {
return when {
reference.startsWith(LINUX_LIBSECRET_PREFIX) -> reference.removePrefix(LINUX_LIBSECRET_PREFIX)
reference.startsWith(LINUX_SECRET_TOOL_PREFIX) -> reference.removePrefix(LINUX_SECRET_TOOL_PREFIX)
else -> ""
}.ifBlank { linuxSecretKey(keyName) }
}
private object WindowsSecretCodec : DesktopSecretCodec {
override val name: String = "windows"

View file

@ -38,6 +38,9 @@ import androidx.compose.ui.window.application
import androidx.compose.ui.window.rememberWindowState
import com.aryan.reader.shared.AppContrastOption
import com.aryan.reader.shared.AppThemeMode
import com.aryan.reader.shared.reader.ReaderReadingMode
import com.aryan.reader.shared.reader.ReaderSettings
import com.aryan.reader.shared.reader.SharedJvmBookLoadSemanticMode
import com.aryan.reader.shared.ui.SharedAppTheme
import com.aryan.reader.shared.ui.readerString
import kotlinx.coroutines.Dispatchers
@ -629,6 +632,26 @@ internal fun desktopEpubWebViewUsesWebView2(
return desktopEpubWebViewBackend(platform) == DesktopEpubWebViewBackend.WINDOWS_WEBVIEW2
}
internal fun desktopShouldUseNativeVerticalEpubReader(
platform: DesktopPlatform = currentDesktopPlatform()
): Boolean {
return platform.os == DesktopOperatingSystem.LINUX
}
internal fun desktopEpubBookLoadSemanticMode(
settings: ReaderSettings,
platform: DesktopPlatform = currentDesktopPlatform()
): SharedJvmBookLoadSemanticMode {
return if (
settings.readingMode == ReaderReadingMode.VERTICAL &&
!desktopShouldUseNativeVerticalEpubReader(platform)
) {
SharedJvmBookLoadSemanticMode.SKIP
} else {
SharedJvmBookLoadSemanticMode.FULL
}
}
internal fun desktopEpubWebViewCanRender(
state: DesktopWebViewRuntimeState,
platform: DesktopPlatform = currentDesktopPlatform()

View file

@ -370,17 +370,34 @@ internal class DesktopAuthStore(
}
fun save(session: DesktopAuthSession) {
val protectedRefreshToken = protectRequired(RefreshTokenKey, session.refreshToken)
val protectedGoogleRefreshToken = session.googleRefreshToken
.takeIf { it.isNotBlank() }
?.let { protectRequired(GoogleRefreshTokenKey, it) }
if (session.refreshToken.isBlank()) {
throw IllegalArgumentException("Cannot save a desktop account without a refresh token.")
}
val protectedTokens = runCatching {
ProtectedDesktopAuthTokens(
refreshToken = protectRequired(RefreshTokenKey, session.refreshToken),
googleRefreshToken = session.googleRefreshToken
.takeIf { it.isNotBlank() }
?.let { protectRequired(GoogleRefreshTokenKey, it) }
)
}.getOrElse { error ->
if (secretCodec.isAvailable) {
throw error
}
logDesktopCloudSync {
"desktop.auth.persist_skipped reason=secure_storage_unavailable codec=${secretCodec.name} " +
"error=\"${error.desktopTtsSummary()}\""
}
clear()
return
}
val properties = Properties().apply {
setProperty("uid", session.user.uid)
setProperty("displayName", session.user.displayName.orEmpty())
setProperty("photoUrl", session.user.photoUrl.orEmpty())
setProperty("email", session.user.email.orEmpty())
setProperty(RefreshTokenKey, protectedRefreshToken)
protectedGoogleRefreshToken?.let { setProperty(GoogleRefreshTokenKey, it) }
setProperty(RefreshTokenKey, protectedTokens.refreshToken)
protectedTokens.googleRefreshToken?.let { setProperty(GoogleRefreshTokenKey, it) }
}
settingsFile.storePropertiesAtomically(properties, "Episteme desktop account")
}
@ -396,10 +413,12 @@ internal class DesktopAuthStore(
const val GoogleRefreshTokenKey = "googleRefreshTokenProtected"
}
private data class ProtectedDesktopAuthTokens(
val refreshToken: String,
val googleRefreshToken: String?
)
private fun protectRequired(keyName: String, value: String): String {
if (value.isBlank()) {
throw IllegalArgumentException("Cannot save a desktop account without a refresh token.")
}
val protectedValue = secretCodec.protect(keyName, value)
if (protectedValue.isBlank()) {
throw IllegalStateException("Desktop secure key storage returned an empty value for $keyName.")

View file

@ -84,72 +84,74 @@ internal fun DesktopPdfFullscreenBottomChrome(
val chromeContent = MaterialTheme.colorScheme.onSurface
val sliderActive = MaterialTheme.colorScheme.primary
val sliderInactive = chromeContent.copy(alpha = if (chromeBackground.luminance() > 0.5f) 0.44f else 0.52f)
Surface(
modifier = Modifier
.fillMaxWidth(),
shape = RoundedCornerShape(0.dp),
color = chromeBackground,
contentColor = chromeContent,
tonalElevation = 0.dp,
shadowElevation = 1.dp,
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant)
) {
Column(modifier = Modifier.fillMaxWidth()) {
extraContent()
val hasJumpTargets = jumpBackPage != null || jumpForwardPage != null
DesktopPdfJumpHistoryControls(
visible = showJumpHistory,
backPage = jumpBackPage,
forwardPage = jumpForwardPage,
onBack = onJumpBack,
onForward = onJumpForward,
onClear = onClearJumpHistory
)
if (showJumpHistory && hasJumpTargets) {
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
}
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
ReaderTooltipIconButton(
tooltip = readerString("desktop_previous_page", "Previous page"),
onClick = onPrevious,
enabled = canGoPrevious
) {
Icon(
Icons.AutoMirrored.Filled.NavigateBefore,
contentDescription = readerString("desktop_previous_page", "Previous page"),
tint = chromeContent.copy(alpha = if (canGoPrevious) 0.78f else 0.32f)
)
Column(modifier = Modifier.fillMaxWidth()) {
extraContent()
Surface(
modifier = Modifier
.fillMaxWidth(),
shape = RoundedCornerShape(0.dp),
color = chromeBackground,
contentColor = chromeContent,
tonalElevation = 0.dp,
shadowElevation = 1.dp,
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant)
) {
Column(modifier = Modifier.fillMaxWidth()) {
val hasJumpTargets = jumpBackPage != null || jumpForwardPage != null
DesktopPdfJumpHistoryControls(
visible = showJumpHistory,
backPage = jumpBackPage,
forwardPage = jumpForwardPage,
onBack = onJumpBack,
onForward = onJumpForward,
onClear = onClearJumpHistory
)
if (showJumpHistory && hasJumpTargets) {
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
}
Text(
pageLabel,
style = MaterialTheme.typography.labelSmall,
color = chromeContent.copy(alpha = 0.72f)
)
ReaderMinimalSlider(
value = pageIndex.toFloat(),
onValueChange = onPageScrub,
onValueChangeFinished = onPageScrubFinished,
valueRange = 0f..(pageCount - 1).coerceAtLeast(0).toFloat(),
enabled = pageCount > 1,
activeColor = sliderActive,
inactiveColor = sliderInactive,
thumbColor = sliderActive,
modifier = Modifier.weight(1f)
)
ReaderTooltipIconButton(
tooltip = readerString("desktop_next_page", "Next page"),
onClick = onNext,
enabled = canGoNext
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.AutoMirrored.Filled.NavigateNext,
contentDescription = readerString("desktop_next_page", "Next page"),
tint = chromeContent.copy(alpha = if (canGoNext) 0.78f else 0.32f)
ReaderTooltipIconButton(
tooltip = readerString("desktop_previous_page", "Previous page"),
onClick = onPrevious,
enabled = canGoPrevious
) {
Icon(
Icons.AutoMirrored.Filled.NavigateBefore,
contentDescription = readerString("desktop_previous_page", "Previous page"),
tint = chromeContent.copy(alpha = if (canGoPrevious) 0.78f else 0.32f)
)
}
Text(
pageLabel,
style = MaterialTheme.typography.labelSmall,
color = chromeContent.copy(alpha = 0.72f)
)
ReaderMinimalSlider(
value = pageIndex.toFloat(),
onValueChange = onPageScrub,
onValueChangeFinished = onPageScrubFinished,
valueRange = 0f..(pageCount - 1).coerceAtLeast(0).toFloat(),
enabled = pageCount > 1,
activeColor = sliderActive,
inactiveColor = sliderInactive,
thumbColor = sliderActive,
modifier = Modifier.weight(1f)
)
ReaderTooltipIconButton(
tooltip = readerString("desktop_next_page", "Next page"),
onClick = onNext,
enabled = canGoNext
) {
Icon(
Icons.AutoMirrored.Filled.NavigateNext,
contentDescription = readerString("desktop_next_page", "Next page"),
tint = chromeContent.copy(alpha = if (canGoNext) 0.78f else 0.32f)
)
}
}
}
}
@ -180,78 +182,80 @@ internal fun DesktopPdfBottomChrome(
val chromeContent = MaterialTheme.colorScheme.onSurface
val sliderActive = MaterialTheme.colorScheme.primary
val sliderInactive = chromeContent.copy(alpha = if (chromeBackground.luminance() > 0.5f) 0.44f else 0.52f)
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(0.dp),
color = chromeBackground,
contentColor = chromeContent,
tonalElevation = 0.dp,
shadowElevation = 1.dp,
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant)
) {
Column(modifier = Modifier.fillMaxWidth()) {
extraContent()
DesktopPdfJumpHistoryControls(
visible = showJumpHistory,
backPage = jumpBackPage,
forwardPage = jumpForwardPage,
onBack = onJumpBack,
onForward = onJumpForward,
onClear = onClearJumpHistory
)
if (showJumpHistory && (jumpBackPage != null || jumpForwardPage != null)) {
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
}
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
ReaderTooltipIconButton(
tooltip = readerString("desktop_previous_page", "Previous page"),
onClick = onPrevious,
enabled = canGoPrevious
) {
Icon(
Icons.AutoMirrored.Filled.NavigateBefore,
contentDescription = readerString("desktop_previous_page", "Previous page"),
tint = chromeContent.copy(alpha = if (canGoPrevious) 0.78f else 0.32f)
)
}
Text(
pageLabel,
style = MaterialTheme.typography.labelSmall,
color = chromeContent.copy(alpha = 0.72f)
Column(modifier = Modifier.fillMaxWidth()) {
extraContent()
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(0.dp),
color = chromeBackground,
contentColor = chromeContent,
tonalElevation = 0.dp,
shadowElevation = 1.dp,
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant)
) {
Column(modifier = Modifier.fillMaxWidth()) {
DesktopPdfJumpHistoryControls(
visible = showJumpHistory,
backPage = jumpBackPage,
forwardPage = jumpForwardPage,
onBack = onJumpBack,
onForward = onJumpForward,
onClear = onClearJumpHistory
)
if (pageCount > 1) {
ReaderMinimalSlider(
value = pageIndex.toFloat(),
onValueChange = onPageScrub,
onValueChangeFinished = onPageScrubFinished,
valueRange = 0f..(pageCount - 1).toFloat(),
activeColor = sliderActive,
inactiveColor = sliderInactive,
thumbColor = sliderActive,
modifier = Modifier.weight(1f)
)
} else {
Spacer(Modifier.weight(1f))
if (showJumpHistory && (jumpBackPage != null || jumpForwardPage != null)) {
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
}
Text(
"${progressPercent.toInt()}%",
style = MaterialTheme.typography.labelSmall,
color = chromeContent.copy(alpha = 0.72f)
)
ReaderTooltipIconButton(
tooltip = readerString("desktop_next_page", "Next page"),
onClick = onNext,
enabled = canGoNext
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.AutoMirrored.Filled.NavigateNext,
contentDescription = readerString("desktop_next_page", "Next page"),
tint = chromeContent.copy(alpha = if (canGoNext) 0.78f else 0.32f)
ReaderTooltipIconButton(
tooltip = readerString("desktop_previous_page", "Previous page"),
onClick = onPrevious,
enabled = canGoPrevious
) {
Icon(
Icons.AutoMirrored.Filled.NavigateBefore,
contentDescription = readerString("desktop_previous_page", "Previous page"),
tint = chromeContent.copy(alpha = if (canGoPrevious) 0.78f else 0.32f)
)
}
Text(
pageLabel,
style = MaterialTheme.typography.labelSmall,
color = chromeContent.copy(alpha = 0.72f)
)
if (pageCount > 1) {
ReaderMinimalSlider(
value = pageIndex.toFloat(),
onValueChange = onPageScrub,
onValueChangeFinished = onPageScrubFinished,
valueRange = 0f..(pageCount - 1).toFloat(),
activeColor = sliderActive,
inactiveColor = sliderInactive,
thumbColor = sliderActive,
modifier = Modifier.weight(1f)
)
} else {
Spacer(Modifier.weight(1f))
}
Text(
"${progressPercent.toInt()}%",
style = MaterialTheme.typography.labelSmall,
color = chromeContent.copy(alpha = 0.72f)
)
ReaderTooltipIconButton(
tooltip = readerString("desktop_next_page", "Next page"),
onClick = onNext,
enabled = canGoNext
) {
Icon(
Icons.AutoMirrored.Filled.NavigateNext,
contentDescription = readerString("desktop_next_page", "Next page"),
tint = chromeContent.copy(alpha = if (canGoNext) 0.78f else 0.32f)
)
}
}
}
}

View file

@ -62,6 +62,7 @@ import com.aryan.reader.shared.ui.DesktopEpubNativeImage
import com.aryan.reader.shared.ui.ReaderContentRenderPlan
import com.aryan.reader.shared.ui.SharedNativePaginatedReader
import com.aryan.reader.shared.ui.SharedNativeReaderSelectionAction
import com.aryan.reader.shared.ui.SharedNativeVerticalReader
import com.aryan.reader.shared.ui.SharedReaderScreen
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
@ -627,6 +628,7 @@ internal fun DesktopReaderScreen(
},
readerCustomTextureIds = readerCustomTextureIds,
onImportReaderTexture = onImportReaderTexture,
preferNativeVerticalReader = desktopShouldUseNativeVerticalEpubReader(),
bottomChromeExtraContent = bottomChromeExtraContent,
useDetachedChromeLayer = useDetachedChromeLayer,
useDetachedPanelLayer = useDetachedPanelLayer
@ -634,7 +636,7 @@ internal fun DesktopReaderScreen(
val renderPlanModeKey = renderPlan.desktopReaderSurfaceModeKey()
val readerSurfaceKey = renderPlan.desktopReaderSurfaceContentKey(paginatedLayoutReady)
val readerModeSwitchLayoutModifier =
if (renderPlan is ReaderContentRenderPlan.NativePaginatedPages) {
if (renderPlan.desktopReaderUsesNativeComposeSurface()) {
Modifier.onGloballyPositioned { coordinates ->
val bounds = coordinates.boundsInWindow()
logReaderModeSwitch(
@ -713,7 +715,7 @@ internal fun DesktopReaderScreen(
"htmlChars=${(renderPlan as? ReaderContentRenderPlan.WebDocument)?.html?.length ?: 0} " +
"paginatedReady=$paginatedLayoutReady"
}
if (renderPlan is ReaderContentRenderPlan.NativePaginatedPages) {
if (renderPlan.desktopReaderUsesNativeComposeSurface()) {
cleanupRetiredDesktopWebView2InteropHosts(
readerAwtWindow,
"native_surface_state_ready_$paginatedLayoutReady"
@ -900,6 +902,67 @@ internal fun DesktopReaderScreen(
)
}
}
is ReaderContentRenderPlan.NativeVerticalPages -> {
LaunchedEffect(renderPlan.book.id, renderPlan.pages, renderPlan.currentPageIndex) {
logReaderModeSwitch(
"native_vertical_reader_render currentPage=${renderPlan.currentPageIndex + 1} " +
"pages=${renderPlan.pages.size} chapters=${renderPlan.book.chapters.size} " +
"semanticBlocks=${renderPlan.book.chapters.sumOf { it.semanticBlocks.size }} " +
"background=${renderPlan.background} foreground=${renderPlan.foreground}"
)
}
Box(
modifier = Modifier
.fillMaxSize()
.onGloballyPositioned { coordinates ->
val bounds = coordinates.boundsInWindow()
logReaderModeSwitch(
"native_vertical_reader_content_layout size=${coordinates.size.width}x${coordinates.size.height} " +
"windowBounds=${bounds.left.formatLogFloat()},${bounds.top.formatLogFloat()} " +
"${bounds.width.formatLogFloat()}x${bounds.height.formatLogFloat()} " +
"currentPage=${renderPlan.currentPageIndex + 1}"
)
}
) {
SharedNativeVerticalReader(
renderPlan = renderPlan,
readerFontFamily = renderPlan.settings.toDesktopReaderFontFamily(),
searchHighlight = MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.72f),
onVisiblePageChanged = onVisiblePageChanged,
enabledSelectionActions = nativeSelectionActions,
onCopyText = { text -> clipboardManager.setText(AnnotatedString(text)) },
onSelectionAction = handleNativeSelectionAction,
onOpenHighlightPaletteManager = onOpenHighlightPaletteManager,
onHighlightCreated = { highlight ->
logDesktopHighlightMap(
"native_vertical_state_reduce_start id=${highlight.id.logPreview(80)} before=${session.highlights.size} " +
"color=${highlight.color.id} chapter=${highlight.chapterIndex} " +
"page=${highlight.locator.pageIndex} offsets=${highlight.locator.startOffset}..${highlight.locator.endOffset} " +
"block=${highlight.locator.blockIndex} char=${highlight.locator.charOffset} " +
"textChars=${highlight.text.length} cfi=\"${highlight.cfi.logPreview(160)}\""
)
val nextSession = session.reduce(ReaderAction.HighlightCreated(highlight), readerEngine)
logDesktopHighlightMap(
"native_vertical_state_reduce_done id=${highlight.id.logPreview(80)} after=${nextSession.highlights.size} " +
"contains=${nextSession.highlights.any { it.id == highlight.id }}"
)
onSessionChange(nextSession)
},
onHighlightSelected = onHighlightSelected,
onLinkClicked = { link ->
handleDesktopEpubLinkClicked(link.toDesktopEpubLinkClick())
},
onReaderTap = onChromeActivity,
imageContent = { image, imageModifier ->
DesktopEpubNativeImage(
image = image,
modifier = imageModifier
)
},
modifier = Modifier.fillMaxSize()
)
}
}
}
}
}
@ -933,6 +996,7 @@ private fun ReaderContentRenderPlan.desktopReaderSurfaceModeKey(): String {
return when (this) {
is ReaderContentRenderPlan.WebDocument -> "desktop-reader-web"
is ReaderContentRenderPlan.NativePaginatedPages -> "desktop-reader-native"
is ReaderContentRenderPlan.NativeVerticalPages -> "desktop-reader-native-vertical"
}
}
@ -940,6 +1004,7 @@ private fun ReaderContentRenderPlan.desktopReaderSurfaceModeLabel(): String {
return when (this) {
is ReaderContentRenderPlan.WebDocument -> "web"
is ReaderContentRenderPlan.NativePaginatedPages -> "native"
is ReaderContentRenderPlan.NativeVerticalPages -> "native-vertical"
}
}
@ -948,9 +1013,15 @@ private fun ReaderContentRenderPlan.desktopReaderSurfaceContentKey(paginatedLayo
is ReaderContentRenderPlan.WebDocument -> "desktop-reader-web"
is ReaderContentRenderPlan.NativePaginatedPages ->
"desktop-reader-native-${if (paginatedLayoutReady) "ready" else "preparing"}"
is ReaderContentRenderPlan.NativeVerticalPages -> "desktop-reader-native-vertical"
}
}
private fun ReaderContentRenderPlan.desktopReaderUsesNativeComposeSurface(): Boolean {
return this is ReaderContentRenderPlan.NativePaginatedPages ||
this is ReaderContentRenderPlan.NativeVerticalPages
}
private fun Window?.requestDesktopReaderModeSwitchRepaint(reason: String) {
val targetWindow = this
EventQueue.invokeLater {

View file

@ -80,7 +80,6 @@ import com.aryan.reader.shared.opds.SharedOpdsStreamUri
import com.aryan.reader.shared.pdf.SharedPdfReaderViewport
import com.aryan.reader.shared.reader.ReaderEngine
import com.aryan.reader.shared.reader.ReaderImageReference
import com.aryan.reader.shared.reader.ReaderReadingMode
import com.aryan.reader.shared.reader.ReaderSessionState
import com.aryan.reader.shared.reader.ReaderSettings
import com.aryan.reader.shared.reader.SharedEpubMetadataEditor
@ -3472,11 +3471,7 @@ internal fun EpistemeDesktopApp(
val readerFile = File(path)
val settingsStartedAt = System.nanoTime()
val restoredSettings = resolvedDesktopReaderSettings(book, readerDefaultSettings)
val semanticMode = if (restoredSettings.readingMode == ReaderReadingMode.VERTICAL) {
SharedJvmBookLoadSemanticMode.SKIP
} else {
SharedJvmBookLoadSemanticMode.FULL
}
val semanticMode = desktopEpubBookLoadSemanticMode(restoredSettings)
val preparedHtmlChapterRange = if (semanticMode == SharedJvmBookLoadSemanticMode.SKIP) {
val initialChapter = book.readerPosition?.chapterIndex?.takeIf { it >= 0 } ?: 0
(initialChapter - DesktopVerticalInitialPreparedHtmlChapterRadius).coerceAtLeast(0)..

View file

@ -32,12 +32,25 @@ class DesktopAuthStoreTest {
}
@Test
fun `save fails without leaving a partial account file when secure storage is unavailable`() {
fun `save falls back to session only without leaving a partial account file when secure storage is unavailable`() {
val settingsFile = Files.createTempDirectory("reader-auth-store-unavailable")
.resolve("auth.properties")
.toFile()
val store = DesktopAuthStore(settingsFile, ThrowingSecretCodec)
store.save(testSession())
assertFalse(settingsFile.exists())
assertEquals(null, store.load())
}
@Test
fun `save still surfaces secure storage write failures when storage is available`() {
val settingsFile = Files.createTempDirectory("reader-auth-store-available-write-failure")
.resolve("auth.properties")
.toFile()
val store = DesktopAuthStore(settingsFile, AvailableThrowingSecretCodec)
assertFailsWith<IllegalStateException> {
store.save(testSession())
}
@ -82,4 +95,14 @@ class DesktopAuthStoreTest {
override fun unprotect(value: String): String = ""
}
private object AvailableThrowingSecretCodec : DesktopSecretCodec {
override val isAvailable: Boolean = true
override fun protect(value: String): String {
throw IllegalStateException("Secure storage write failed")
}
override fun unprotect(value: String): String = ""
}
}

View file

@ -1,6 +1,9 @@
package com.aryan.reader.desktop
import com.aryan.reader.shared.SharedReaderScreenState
import com.aryan.reader.shared.reader.ReaderReadingMode
import com.aryan.reader.shared.reader.ReaderSettings
import com.aryan.reader.shared.reader.SharedJvmBookLoadSemanticMode
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
@ -59,6 +62,33 @@ class DesktopStartupTest {
assertFalse(desktopEpubWebViewCanRender(DesktopWebViewRuntimeState(initialized = true), other))
}
@Test
fun `desktop vertical epub native reader is Linux only`() {
val windows = DesktopPlatform(DesktopOperatingSystem.WINDOWS, DesktopArchitecture.X64)
val linux = DesktopPlatform(DesktopOperatingSystem.LINUX, DesktopArchitecture.X64)
val macos = DesktopPlatform(DesktopOperatingSystem.MACOS, DesktopArchitecture.ARM64)
val other = DesktopPlatform(DesktopOperatingSystem.OTHER, DesktopArchitecture.X64)
assertTrue(desktopShouldUseNativeVerticalEpubReader(linux))
assertFalse(desktopShouldUseNativeVerticalEpubReader(windows))
assertFalse(desktopShouldUseNativeVerticalEpubReader(macos))
assertFalse(desktopShouldUseNativeVerticalEpubReader(other))
}
@Test
fun `desktop vertical epub load keeps semantic blocks for Linux native reader`() {
val windows = DesktopPlatform(DesktopOperatingSystem.WINDOWS, DesktopArchitecture.X64)
val linux = DesktopPlatform(DesktopOperatingSystem.LINUX, DesktopArchitecture.X64)
val macos = DesktopPlatform(DesktopOperatingSystem.MACOS, DesktopArchitecture.ARM64)
val verticalSettings = ReaderSettings(readingMode = ReaderReadingMode.VERTICAL)
val paginatedSettings = ReaderSettings(readingMode = ReaderReadingMode.PAGINATED)
assertEquals(SharedJvmBookLoadSemanticMode.FULL, desktopEpubBookLoadSemanticMode(verticalSettings, linux))
assertEquals(SharedJvmBookLoadSemanticMode.SKIP, desktopEpubBookLoadSemanticMode(verticalSettings, windows))
assertEquals(SharedJvmBookLoadSemanticMode.SKIP, desktopEpubBookLoadSemanticMode(verticalSettings, macos))
assertEquals(SharedJvmBookLoadSemanticMode.FULL, desktopEpubBookLoadSemanticMode(paginatedSettings, windows))
}
@Test
fun `native webview unavailable messages point to the platform runtime`() {
val windowsMessage = desktopNativeWebViewUnavailableMessage(

View file

@ -2,9 +2,44 @@ package com.aryan.reader.desktop
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class LinuxSecretToolCodecTest {
@Test
fun `libsecret codec stores looks up legacy secret tool references and clears secrets by key`() {
val client = FakeLinuxSecretServiceClient()
val codec = LinuxLibsecretCodec(client)
assertTrue(codec.isAvailable)
val reference = codec.protect("geminiKeyProtected", "linux_gemini_key")
assertTrue(reference.startsWith("linux-libsecret:"))
assertEquals("linux_gemini_key", codec.unprotect("geminiKeyProtected", reference))
assertEquals(
"linux_gemini_key",
codec.unprotect("geminiKeyProtected", "secret-tool:Episteme.Reader.geminiKeyProtected")
)
codec.delete("geminiKeyProtected")
assertTrue(client.storedSecrets.isEmpty())
}
@Test
fun `linux secret service codec falls back to secret tool when libsecret is unavailable`() {
val runner = FakeSecretCommandRunner()
val codec = LinuxSecretServiceCodec(
libsecretCodec = LinuxLibsecretCodec(FakeLinuxSecretServiceClient(available = false)),
secretToolCodec = LinuxSecretToolCodec(runner)
)
assertTrue(codec.isAvailable)
val reference = codec.protect("geminiKeyProtected", "linux_gemini_key")
assertTrue(reference.startsWith("secret-tool:"))
assertEquals("linux_gemini_key", codec.unprotect("geminiKeyProtected", reference))
assertFalse(runner.storedSecrets.isEmpty())
}
@Test
fun `secret tool codec stores looks up and clears secrets by key`() {
val runner = FakeSecretCommandRunner()
@ -18,6 +53,31 @@ class LinuxSecretToolCodecTest {
assertTrue(runner.storedSecrets.isEmpty())
}
private class FakeLinuxSecretServiceClient(
private val available: Boolean = true
) : LinuxSecretServiceClient {
val storedSecrets = linkedMapOf<String, String>()
override val isAvailable: Boolean
get() = available
override fun store(key: String, label: String, password: String) {
check(available) { "libsecret unavailable" }
storedSecrets[key] = password
}
override fun lookup(key: String): String? {
check(available) { "libsecret unavailable" }
return storedSecrets[key]
}
override fun clear(key: String) {
if (available) {
storedSecrets.remove(key)
}
}
}
private class FakeSecretCommandRunner : DesktopSecretCommandRunner {
val storedSecrets = linkedMapOf<String, String>()