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:
parent
e4634ed251
commit
6651169ce2
60 changed files with 14457 additions and 468 deletions
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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.")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)..
|
||||
|
|
|
|||
|
|
@ -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 = ""
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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>()
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue