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

@ -1,19 +1,31 @@
import org.gradle.api.GradleException
import org.gradle.api.DefaultTask
import org.gradle.api.file.DirectoryProperty
import org.gradle.api.file.RegularFileProperty
import org.gradle.api.provider.ListProperty
import org.gradle.api.provider.MapProperty
import org.gradle.api.provider.Property
import org.gradle.api.tasks.JavaExec
import org.gradle.api.tasks.Exec
import org.gradle.api.tasks.Input
import org.gradle.api.tasks.InputDirectory
import org.gradle.api.tasks.InputFile
import org.gradle.api.tasks.OutputDirectory
import org.gradle.api.tasks.OutputFile
import org.gradle.api.tasks.PathSensitive
import org.gradle.api.tasks.PathSensitivity
import org.gradle.api.tasks.Sync
import org.gradle.api.tasks.TaskAction
import org.gradle.api.tasks.bundling.Compression
import org.gradle.api.tasks.bundling.Tar
import org.gradle.jvm.tasks.Jar
import org.gradle.process.ExecOperations
import org.jetbrains.compose.desktop.application.dsl.TargetFormat
import org.gradle.work.DisableCachingByDefault
import java.io.File
import java.security.MessageDigest
import java.awt.RenderingHints
import java.awt.image.BufferedImage
import java.nio.file.AtomicMoveNotSupportedException
import java.nio.file.Files
import java.nio.file.StandardCopyOption
@ -21,6 +33,8 @@ import java.util.Properties
import java.util.zip.ZipEntry
import java.util.zip.ZipFile
import java.util.zip.ZipOutputStream
import javax.imageio.ImageIO
import javax.inject.Inject
plugins {
alias(libs.plugins.kotlin.multiplatform)
@ -81,6 +95,244 @@ abstract class RenameDesktopMsiOutputTask : DefaultTask() {
}
}
@DisableCachingByDefault(because = "Generates an MSIX manifest from package metadata.")
abstract class GenerateDesktopMsixManifestTask : DefaultTask() {
@get:Input
abstract val identityName: Property<String>
@get:Input
abstract val publisher: Property<String>
@get:Input
abstract val publisherDisplayName: Property<String>
@get:Input
abstract val packageName: Property<String>
@get:Input
abstract val packageDescription: Property<String>
@get:Input
abstract val packageVersion: Property<String>
@get:Input
abstract val architecture: Property<String>
@get:Input
abstract val executablePath: Property<String>
@get:OutputFile
abstract val outputFile: RegularFileProperty
@TaskAction
fun generate() {
fun xmlEscaped(value: String): String {
return value.replace("&", "&amp;")
.replace("\"", "&quot;")
.replace("'", "&apos;")
.replace("<", "&lt;")
.replace(">", "&gt;")
}
val file = outputFile.get().asFile
file.parentFile.mkdirs()
file.writeText(
"""
<?xml version="1.0" encoding="utf-8"?>
<Package
xmlns="http://schemas.microsoft.com/appx/manifest/foundation/windows10"
xmlns:uap="http://schemas.microsoft.com/appx/manifest/uap/windows10"
xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities"
IgnorableNamespaces="uap rescap">
<Identity
Name="${xmlEscaped(identityName.get())}"
Publisher="${xmlEscaped(publisher.get())}"
Version="${xmlEscaped(packageVersion.get())}"
ProcessorArchitecture="${xmlEscaped(architecture.get())}" />
<Properties>
<DisplayName>${xmlEscaped(packageName.get())}</DisplayName>
<PublisherDisplayName>${xmlEscaped(publisherDisplayName.get())}</PublisherDisplayName>
<Logo>Assets\StoreLogo.png</Logo>
</Properties>
<Resources>
<Resource Language="en-us" />
</Resources>
<Dependencies>
<TargetDeviceFamily Name="Windows.Desktop" MinVersion="10.0.17763.0" MaxVersionTested="10.0.22621.0" />
</Dependencies>
<Applications>
<Application Id="Episteme" Executable="${xmlEscaped(executablePath.get())}" EntryPoint="Windows.FullTrustApplication">
<uap:VisualElements
DisplayName="${xmlEscaped(packageName.get())}"
Description="${xmlEscaped(packageDescription.get())}"
BackgroundColor="transparent"
Square44x44Logo="Assets\Square44x44Logo.png"
Square150x150Logo="Assets\Square150x150Logo.png" />
</Application>
</Applications>
<Capabilities>
<rescap:Capability Name="runFullTrust" />
</Capabilities>
</Package>
""".trimIndent() + "\n",
Charsets.UTF_8
)
}
}
@DisableCachingByDefault(because = "Generates fixed-size MSIX logo assets from the desktop icon.")
abstract class GenerateDesktopMsixAssetsTask : DefaultTask() {
@get:InputFile
@get:PathSensitive(PathSensitivity.NONE)
abstract val sourceIconFile: RegularFileProperty
@get:OutputDirectory
abstract val outputDirectory: DirectoryProperty
@TaskAction
fun generate() {
val source = ImageIO.read(sourceIconFile.get().asFile)
?: throw GradleException("Could not read MSIX source icon ${sourceIconFile.get().asFile.absolutePath}.")
val output = outputDirectory.get().asFile
output.mkdirs()
writePng(source, output.resolve("Square44x44Logo.png"), 44)
writePng(source, output.resolve("Square150x150Logo.png"), 150)
writePng(source, output.resolve("StoreLogo.png"), 50)
}
private fun writePng(source: BufferedImage, target: File, size: Int) {
val image = BufferedImage(size, size, BufferedImage.TYPE_INT_ARGB)
val graphics = image.createGraphics()
try {
graphics.setRenderingHint(
RenderingHints.KEY_INTERPOLATION,
RenderingHints.VALUE_INTERPOLATION_BICUBIC
)
graphics.setRenderingHint(
RenderingHints.KEY_RENDERING,
RenderingHints.VALUE_RENDER_QUALITY
)
graphics.drawImage(source, 0, 0, size, size, null)
} finally {
graphics.dispose()
}
ImageIO.write(image, "png", target)
}
}
@DisableCachingByDefault(because = "Packages the staged MSIX app image with Windows SDK makeappx.")
abstract class PackageDesktopMsixTask @Inject constructor(
private val execOperations: ExecOperations
) : DefaultTask() {
@get:InputDirectory
@get:PathSensitive(PathSensitivity.RELATIVE)
abstract val packageRootDirectory: DirectoryProperty
@get:OutputFile
abstract val outputFile: RegularFileProperty
@get:Input
abstract val makeAppxPath: Property<String>
@get:Input
abstract val hostOsId: Property<String>
@get:Input
abstract val hostArchId: Property<String>
@TaskAction
fun packageMsix() {
if (hostOsId.get() != "windows" || hostArchId.get() != "x64") {
throw GradleException(
"MSIX packaging requires a Windows x64 packaging host. " +
"Current host: ${hostOsId.get()} ${hostArchId.get()}."
)
}
val makeAppx = File(makeAppxPath.get())
if (!makeAppx.isFile) {
throw GradleException(
"Windows SDK makeappx.exe was not found at ${makeAppx.absolutePath}. " +
"Install the Windows SDK MSIX packaging tools or set " +
"-PdesktopMakeAppxPath=<path-to-makeappx.exe>."
)
}
val output = outputFile.get().asFile
output.parentFile.mkdirs()
if (output.exists() && !output.delete()) {
throw GradleException("Could not replace existing MSIX at ${output.absolutePath}.")
}
execOperations.exec {
executable = makeAppx.absolutePath
args(
"pack",
"/d",
packageRootDirectory.get().asFile.absolutePath,
"/p",
output.absolutePath,
"/o"
)
}
}
}
@DisableCachingByDefault(because = "Signs the MSIX package with Windows SDK signtool.")
abstract class SignDesktopMsixTask @Inject constructor(
private val execOperations: ExecOperations
) : DefaultTask() {
@get:InputFile
@get:PathSensitive(PathSensitivity.NONE)
abstract val unsignedMsixFile: RegularFileProperty
@get:InputFile
@get:PathSensitive(PathSensitivity.NONE)
abstract val certificateFile: RegularFileProperty
@get:Input
abstract val signToolPath: Property<String>
@get:Input
abstract val certificatePassword: Property<String>
@get:Input
abstract val timestampUrl: Property<String>
@TaskAction
fun signMsix() {
val signTool = File(signToolPath.get())
if (!signTool.isFile) {
throw GradleException(
"Windows SDK signtool.exe was not found at ${signTool.absolutePath}. " +
"Install the Windows SDK or set -PdesktopSignToolPath=<path-to-signtool.exe>."
)
}
val signArgs = mutableListOf(
"sign",
"/fd",
"SHA256",
"/f",
certificateFile.get().asFile.absolutePath
)
val password = certificatePassword.get().trim()
if (password.isNotEmpty()) {
signArgs += listOf("/p", password)
}
val timestamp = timestampUrl.get().trim()
if (timestamp.isNotEmpty()) {
signArgs += listOf("/tr", timestamp, "/td", "SHA256")
}
signArgs += unsignedMsixFile.get().asFile.absolutePath
execOperations.exec {
executable = signTool.absolutePath
args(signArgs)
}
}
}
@DisableCachingByDefault(because = "Generates local desktop service config for native packages.")
abstract class GenerateDesktopCloudConfigTask : DefaultTask() {
@get:Input
@ -136,6 +388,275 @@ abstract class VerifyDesktopNativePackagingTask : DefaultTask() {
}
}
@DisableCachingByDefault(because = "Generates AUR package metadata from the local Linux distributable.")
abstract class PrepareDesktopAurPackageTask : DefaultTask() {
@get:Input
abstract val aurPackageName: Property<String>
@get:Input
abstract val providedPackageName: Property<String>
@get:Input
abstract val packageVersion: Property<String>
@get:Input
abstract val packageRelease: Property<String>
@get:Input
abstract val packageDescription: Property<String>
@get:Input
abstract val appDisplayName: Property<String>
@get:Input
abstract val installDirectoryName: Property<String>
@get:Input
abstract val launcherName: Property<String>
@get:Input
abstract val executableName: Property<String>
@get:Input
abstract val sourceUrl: Property<String>
@get:Input
abstract val projectUrl: Property<String>
@get:InputFile
@get:PathSensitive(PathSensitivity.NONE)
abstract val linuxTarFile: RegularFileProperty
@get:OutputDirectory
abstract val outputDirectory: DirectoryProperty
@TaskAction
fun prepare() {
val output = outputDirectory.get().asFile
val sourceTar = linuxTarFile.get().asFile
if (!sourceTar.isFile) {
throw GradleException("Missing Linux tarball for AUR packaging: ${sourceTar.absolutePath}")
}
output.deleteRecursively()
output.mkdirs()
val stagedTar = output.resolve(sourceTar.name)
sourceTar.copyTo(stagedTar, overwrite = true)
val sha256 = stagedTar.sha256()
val configuredSourceUrl = sourceUrl.get().trim()
val sourceEntry = if (configuredSourceUrl.isBlank()) {
stagedTar.name
} else {
"${stagedTar.name}::$configuredSourceUrl"
}
output.resolve("PKGBUILD").writeText(
aurPkgbuild(
pkgname = aurPackageName.get(),
providedPackage = providedPackageName.get(),
pkgver = packageVersion.get(),
pkgrel = packageRelease.get(),
pkgdesc = packageDescription.get(),
appName = appDisplayName.get(),
installDir = installDirectoryName.get(),
launcher = launcherName.get(),
executable = executableName.get(),
source = sourceEntry,
sha256 = sha256,
projectUrl = projectUrl.get()
)
)
output.resolve(".SRCINFO").writeText(
aurSrcInfo(
pkgname = aurPackageName.get(),
providedPackage = providedPackageName.get(),
pkgver = packageVersion.get(),
pkgrel = packageRelease.get(),
pkgdesc = packageDescription.get(),
source = sourceEntry,
sha256 = sha256,
projectUrl = projectUrl.get()
)
)
}
private fun File.sha256(): String {
val digest = MessageDigest.getInstance("SHA-256")
inputStream().use { input ->
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
while (true) {
val read = input.read(buffer)
if (read < 0) break
digest.update(buffer, 0, read)
}
}
return digest.digest().joinToString("") { "%02x".format(it) }
}
private fun shellSingleQuoted(value: String): String {
return "'" + value.replace("'", "'\"'\"'") + "'"
}
private fun archRuntimeDependencies(): List<String> {
return listOf(
"alsa-lib",
"atk",
"cairo",
"dbus",
"expat",
"fontconfig",
"freetype2",
"gcc-libs",
"gdk-pixbuf2",
"glib2",
"glibc",
"gtk3",
"libcups",
"libarchive",
"libsecret",
"libx11",
"libxcomposite",
"libxdamage",
"libxext",
"libxi",
"libxrandr",
"libxrender",
"libxtst",
"nss",
"pango",
"zlib"
)
}
private fun aurPkgbuild(
pkgname: String,
providedPackage: String,
pkgver: String,
pkgrel: String,
pkgdesc: String,
appName: String,
installDir: String,
launcher: String,
executable: String,
source: String,
sha256: String,
projectUrl: String
): String {
val desktopFile = "$providedPackage.desktop"
val iconName = providedPackage
val depends = archRuntimeDependencies()
val mimeTypes = archDesktopMimeTypes()
return """
pkgname=${shellSingleQuoted(pkgname)}
pkgver=${shellSingleQuoted(pkgver)}
pkgrel=${shellSingleQuoted(pkgrel)}
pkgdesc=${shellSingleQuoted(pkgdesc)}
arch=('x86_64')
url=${shellSingleQuoted(projectUrl)}
license=('AGPL-3.0-only')
depends=(${depends.joinToString(" ") { shellSingleQuoted(it) }})
provides=(${shellSingleQuoted(providedPackage)})
conflicts=(${shellSingleQuoted(providedPackage)})
source=(${shellSingleQuoted(source)})
sha256sums=(${shellSingleQuoted(sha256)})
options=('!debug')
package() {
install -dm755 "${'$'}pkgdir/opt/$installDir"
cp -a "$installDir/." "${'$'}pkgdir/opt/$installDir/"
chmod 755 "${'$'}pkgdir/opt/$installDir/bin/$executable"
install -dm755 "${'$'}pkgdir/usr/bin"
ln -sf "/opt/$installDir/bin/$executable" "${'$'}pkgdir/usr/bin/$launcher"
install -Dm644 "${'$'}pkgdir/opt/$installDir/share/licenses/LICENSE" "${'$'}pkgdir/usr/share/licenses/${'$'}pkgname/LICENSE"
local icon_path
icon_path="${'$'}(find "${'$'}pkgdir/opt/$installDir" -name 'episteme_icon.png' -print -quit)"
if [[ -n "${'$'}icon_path" ]]; then
install -Dm644 "${'$'}icon_path" "${'$'}pkgdir/usr/share/icons/hicolor/512x512/apps/$iconName.png"
install -Dm644 "${'$'}icon_path" "${'$'}pkgdir/usr/share/pixmaps/$iconName.png"
fi
install -Dm644 /dev/stdin "${'$'}pkgdir/usr/share/applications/$desktopFile" <<'EOF'
[Desktop Entry]
Type=Application
Name=$appName
Comment=$pkgdesc
Exec=$launcher %F
Icon=$iconName
Terminal=false
Categories=Office;Viewer;
MimeType=${mimeTypes.joinToString(";")};
EOF
}
""".trimIndent() + "\n"
}
private fun aurSrcInfo(
pkgname: String,
providedPackage: String,
pkgver: String,
pkgrel: String,
pkgdesc: String,
source: String,
sha256: String,
projectUrl: String
): String {
val depends = archRuntimeDependencies()
return """
pkgbase = $pkgname
pkgdesc = $pkgdesc
pkgver = $pkgver
pkgrel = $pkgrel
url = $projectUrl
arch = x86_64
license = AGPL-3.0-only
${depends.joinToString("\n") { "\tdepends = $it" }}
provides = $providedPackage
conflicts = $providedPackage
source = $source
sha256sums = $sha256
pkgname = $pkgname
""".trimIndent() + "\n"
}
private fun archDesktopMimeTypes(): List<String> {
return listOf(
"application/pdf",
"application/epub+zip",
"application/x-mobipocket-ebook",
"application/vnd.amazon.ebook",
"application/vnd.amazon.mobi8-ebook",
"text/markdown",
"text/x-markdown",
"text/plain",
"text/html",
"application/xhtml+xml",
"application/x-fictionbook+xml",
"application/x-zip-compressed-fb2",
"application/zip",
"application/vnd.comicbook+zip",
"application/x-cbz",
"application/vnd.comicbook-rar",
"application/x-cbr",
"application/x-rar-compressed",
"application/x-cb7",
"application/x-7z-compressed",
"application/vnd.comicbook+tar",
"application/x-cbt",
"application/x-tar",
"application/tar",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
"application/vnd.oasis.opendocument.text",
"application/x-vnd.oasis.opendocument.text-flat-xml"
)
}
}
@DisableCachingByDefault(because = "Strips stale jar signatures in-place after ProGuard rewrites signed dependencies.")
abstract class StripInvalidJarSignaturesTask : DefaultTask() {
@get:Input
@ -546,6 +1067,79 @@ fun normalizeDesktopPackageFormats(
return formats
}
fun normalizeDesktopMsixVersion(rawVersion: String): String {
val parts = rawVersion.trim().split('.')
if (parts.size !in 3..4 || parts.any { it.isBlank() || it.all(Char::isDigit).not() }) {
throw GradleException(
"desktopMsixVersion must be a numeric Windows package version with three or four parts, " +
"for example 1.0.1 or 1.0.1.0."
)
}
val normalized = if (parts.size == 3) parts + "0" else parts
normalized.forEach { part ->
val value = part.toIntOrNull()
if (value == null || value !in 0..65535) {
throw GradleException("desktopMsixVersion part '$part' is outside the MSIX range 0..65535.")
}
}
return normalized.joinToString(".")
}
fun normalizeDesktopMsixIdentityName(rawName: String): String {
val normalized = rawName.trim()
if (!Regex("[A-Za-z0-9][A-Za-z0-9.-]{2,49}").matches(normalized)) {
throw GradleException(
"desktopMsixIdentityName must be 3-50 characters using letters, numbers, dots, or hyphens."
)
}
return normalized
}
fun desktopMsixArchitecture(osArch: String = System.getProperty("os.arch")): String {
return when (desktopArchId(osArch)) {
"x64" -> "x64"
"arm64" -> "arm64"
"x86" -> "x86"
else -> "neutral"
}
}
fun latestExistingFile(candidates: List<File>): File? {
return candidates.filter { it.isFile }.maxByOrNull { it.absolutePath }
}
fun windowsSdkToolCandidates(toolName: String): List<File> {
val roots = listOfNotNull(
System.getenv("WindowsSdkDir")?.let(::File),
File("C:/Program Files (x86)/Windows Kits/10"),
File("C:/Program Files/Windows Kits/10"),
File("C:/Program Files (x86)/Windows Kits/10/App Certification Kit"),
File("C:/Program Files/Windows Kits/10/App Certification Kit")
).distinctBy { it.absolutePath.lowercase() }
val sdkBins = roots.flatMap { root ->
safeChildDirectories(root.resolve("bin")).flatMap { versionDir ->
listOf(
versionDir.resolve("x64/$toolName.exe"),
versionDir.resolve("x86/$toolName.exe"),
versionDir.resolve(toolName)
)
}
}
val directBins = roots.map { root -> root.resolve("$toolName.exe") }
val pathBins = (System.getenv("PATH") ?: "")
.split(File.pathSeparator)
.filter { it.isNotBlank() }
.map { File(it).resolve("$toolName.exe") }
return sdkBins + directBins + pathBins
}
fun findWindowsSdkTool(toolName: String, explicitPath: String?): File {
val explicit = explicitPath?.trim()?.takeIf { it.isNotEmpty() }?.let(::File)
if (explicit != null) return explicit
return latestExistingFile(windowsSdkToolCandidates(toolName))
?: File(rootProject.projectDir, "__missing_windows_sdk_tool__/$toolName.exe")
}
val desktopVersionName = "1.0.1"
val desktopFlavor = providers.gradleProperty("desktopFlavor")
.orElse("standard")
@ -565,19 +1159,49 @@ val desktopPackageVersion = providers.gradleProperty("desktopPackageVersion")
.orElse(desktopResolvedVersionName)
.map(::normalizeDesktopPackageVersion)
val desktopPackageName = if (isOssOfflineDesktop) "Episteme oss" else "Episteme"
val desktopLinuxPackageName = if (isOssOfflineDesktop) "episteme-oss" else "episteme"
val desktopPackageDescription = if (isOssOfflineDesktop) {
"Episteme oss offline desktop reader"
} else {
"Episteme desktop reader"
}
val desktopVendor = providers.gradleProperty("desktopVendor").orElse("Aryan")
val desktopVendorName = desktopVendor.get()
val desktopProjectUrl = providers.gradleProperty("desktopProjectUrl")
.orElse("https://github.com/Aryan-Raj3112/episteme")
val desktopOsName = System.getProperty("os.name")
val desktopOsArch = System.getProperty("os.arch")
val desktopPackageArchitecture = normalizeDesktopPackageArchitecture(desktopOsArch)
val desktopAurPackageName = providers.gradleProperty("desktopAurPackageName")
.orElse(if (isOssOfflineDesktop) "episteme-oss-bin" else "episteme-bin")
val desktopAurPackageRelease = providers.gradleProperty("desktopAurPackageRelease")
.orElse("1")
val desktopAurSourceUrl = providers.gradleProperty("desktopAurSourceUrl")
.orElse("")
val desktopPackageTargetFormats = providers.gradleProperty("desktopPackageFormats")
.orElse(desktopDefaultPackageFormats(desktopOsName))
.map { normalizeDesktopPackageFormats(it, desktopOsName) }
.get()
val desktopMsixIdentityName = providers.gradleProperty("desktopMsixIdentityName")
.orElse(if (isOssOfflineDesktop) "Aryan.EpistemeOss" else "Aryan.Episteme")
.map(::normalizeDesktopMsixIdentityName)
.get()
val desktopMsixPublisher = providers.gradleProperty("desktopMsixPublisher")
.orElse("CN=$desktopVendorName")
val desktopMsixPublisherDisplayName = providers.gradleProperty("desktopMsixPublisherDisplayName")
.orElse(desktopVendor)
val desktopMsixVersion = providers.gradleProperty("desktopMsixVersion")
.orElse(desktopPackageVersion)
.map(::normalizeDesktopMsixVersion)
.get()
val desktopMsixArchitecture = desktopMsixArchitecture(desktopOsArch)
val desktopMakeAppxPath = providers.gradleProperty("desktopMakeAppxPath").orNull
val desktopSignToolPath = providers.gradleProperty("desktopSignToolPath").orNull
val desktopMsixCertificatePath = providers.gradleProperty("desktopMsixCertificatePath").orNull
val desktopMsixCertificatePassword = providers.gradleProperty("desktopMsixCertificatePassword")
.orElse("")
val desktopMsixTimestampUrl = providers.gradleProperty("desktopMsixTimestampUrl")
.orElse("http://timestamp.digicert.com")
val desktopNativePackageSupportedHost = desktopOsId(desktopOsName) in setOf("windows", "linux") &&
desktopArchId(desktopOsArch) == "x64"
val desktopReleaseProguardEnabled = providers.gradleProperty("desktopReleaseProguard")
@ -688,6 +1312,127 @@ val verifyDesktopNativePackaging by tasks.registering(VerifyDesktopNativePackagi
missingStandardServiceConfig.set(desktopMissingStandardServiceConfig)
}
val desktopDistributableAppDir = layout.buildDirectory.dir("compose/binaries/main/app/$desktopPackageName")
val desktopReleaseDistributableAppDir = layout.buildDirectory.dir("compose/binaries/main-release/app/$desktopPackageName")
val desktopLinuxTarFileName = "${desktopLinuxPackageName}-${desktopPackageVersion.get()}-linux-$desktopPackageArchitecture.tar.gz"
val desktopAurOutputDir = layout.buildDirectory.dir("aur/${desktopAurPackageName.get()}")
val desktopMsixPackageDir = layout.buildDirectory.dir("msix/package")
val desktopMsixAssetsDir = layout.buildDirectory.dir("msix/generated/assets")
val desktopMsixManifestFile = layout.buildDirectory.file("msix/generated/AppxManifest.xml")
val desktopMsixOutputFile = layout.buildDirectory.file(
"compose/binaries/main-release/msix/${desktopLinuxPackageName}-${desktopPackageVersion.get()}-windows-$desktopPackageArchitecture.msix"
)
val packageLinuxTar by tasks.registering(Tar::class) {
group = "distribution"
description = "Packages the Linux desktop distributable as a tar.gz for Arch/AUR packaging."
dependsOn("createDistributable")
archiveFileName.set(desktopLinuxTarFileName)
destinationDirectory.set(layout.buildDirectory.dir("compose/binaries/main/linux-tar"))
compression = Compression.GZIP
from(desktopDistributableAppDir) {
into(desktopLinuxPackageName)
}
from(desktopLinuxIconFile) {
into("$desktopLinuxPackageName/share")
}
from(rootProject.layout.projectDirectory.file("LICENSE")) {
into("$desktopLinuxPackageName/share/licenses")
}
}
val prepareAurPackage by tasks.registering(PrepareDesktopAurPackageTask::class) {
group = "distribution"
description = "Generates a local AUR package directory with PKGBUILD and .SRCINFO."
dependsOn(packageLinuxTar)
aurPackageName.set(desktopAurPackageName)
providedPackageName.set(desktopLinuxPackageName)
packageVersion.set(desktopPackageVersion)
packageRelease.set(desktopAurPackageRelease)
packageDescription.set(desktopPackageDescription)
appDisplayName.set(desktopPackageName)
installDirectoryName.set(desktopLinuxPackageName)
launcherName.set(desktopLinuxPackageName)
executableName.set(desktopPackageName)
sourceUrl.set(desktopAurSourceUrl)
projectUrl.set(desktopProjectUrl)
linuxTarFile.set(packageLinuxTar.flatMap { it.archiveFile })
outputDirectory.set(desktopAurOutputDir)
}
tasks.register<Exec>("packageAur") {
group = "distribution"
description = "Builds the generated AUR package with makepkg. Run this on Arch Linux."
dependsOn(prepareAurPackage)
commandLine("makepkg", "-sf", "--cleanbuild")
workingDir = desktopAurOutputDir.get().asFile
}
val generateDesktopMsixManifest by tasks.registering(GenerateDesktopMsixManifestTask::class) {
identityName.set(desktopMsixIdentityName)
publisher.set(desktopMsixPublisher)
publisherDisplayName.set(desktopMsixPublisherDisplayName)
packageName.set(desktopPackageName)
packageDescription.set(desktopPackageDescription)
packageVersion.set(desktopMsixVersion)
architecture.set(desktopMsixArchitecture)
executablePath.set("$desktopPackageName.exe")
outputFile.set(desktopMsixManifestFile)
}
val generateDesktopMsixAssets by tasks.registering(GenerateDesktopMsixAssetsTask::class) {
sourceIconFile.set(desktopLinuxIconFile)
outputDirectory.set(desktopMsixAssetsDir)
}
val prepareReleaseMsixPackage by tasks.registering(Sync::class) {
group = "distribution"
description = "Stages the release Windows app image and MSIX metadata for makeappx."
dependsOn("createReleaseDistributable", generateDesktopMsixManifest, generateDesktopMsixAssets)
from(desktopReleaseDistributableAppDir)
from(desktopMsixManifestFile)
from(desktopMsixAssetsDir) {
into("Assets")
}
into(desktopMsixPackageDir)
}
val packageReleaseMsix by tasks.registering(PackageDesktopMsixTask::class) {
group = "distribution"
description = "Packages the release Windows app image as an MSIX using Windows SDK makeappx."
dependsOn(prepareReleaseMsixPackage)
val makeAppx = findWindowsSdkTool("makeappx", desktopMakeAppxPath)
packageRootDirectory.set(desktopMsixPackageDir)
outputFile.set(desktopMsixOutputFile)
makeAppxPath.set(makeAppx.absolutePath)
hostOsId.set(desktopOsId(desktopOsName))
hostArchId.set(desktopArchId(desktopOsArch))
}
val signReleaseMsix = desktopMsixCertificatePath?.trim()?.takeIf { it.isNotEmpty() }?.let { certificatePath ->
tasks.register<SignDesktopMsixTask>("signReleaseMsix") {
group = "distribution"
description = "Signs the release MSIX with signtool when -PdesktopMsixCertificatePath is configured."
dependsOn(packageReleaseMsix)
val signTool = findWindowsSdkTool("signtool", desktopSignToolPath)
val resolvedCertificateFile = File(certificatePath).let { file ->
if (file.isAbsolute) file else project.file(certificatePath)
}
unsignedMsixFile.set(desktopMsixOutputFile)
certificateFile.set(resolvedCertificateFile)
signToolPath.set(signTool.absolutePath)
certificatePassword.set(desktopMsixCertificatePassword)
timestampUrl.set(desktopMsixTimestampUrl)
}
}
kotlin {
jvm("desktop")
jvmToolchain(21)
@ -771,7 +1516,7 @@ compose.desktop {
}
linux {
iconFile.set(desktopLinuxIconFile)
packageName = if (isOssOfflineDesktop) "episteme-oss" else "episteme"
packageName = desktopLinuxPackageName
debMaintainer = "epistemereader@gmail.com"
menuGroup = "Office"
appCategory = "Office"
@ -818,6 +1563,7 @@ tasks.matching {
"packageReleaseDistributionForCurrentOS",
"packageReleaseExe",
"packageReleaseMsi",
"packageReleaseMsix",
"packageReleaseDeb",
"packageReleaseRpm",
"runReleaseDistributable"
@ -853,10 +1599,16 @@ tasks.matching {
"packageReleaseExe",
"packageMsi",
"packageReleaseMsi",
"prepareReleaseMsixPackage",
"packageReleaseMsix",
"signReleaseMsix",
"packageDeb",
"packageReleaseDeb",
"packageRpm",
"packageReleaseRpm",
"packageLinuxTar",
"prepareAurPackage",
"packageAur",
"runDistributable",
"runReleaseDistributable"
)

View file

@ -0,0 +1,229 @@
# Desktop package builds
Build Linux packages on the matching distro VM when testing manually:
```bash
cd ~/Reader
./gradlew :desktopApp:packageDeb -x test
./gradlew :desktopApp:packageRpm -x test
./gradlew :desktopApp:packageAur -x test
```
Build a Windows MSIX locally on Windows with the Windows SDK installed:
```powershell
cd C:\Users\aryan\Desktop\Reader
.\gradlew.bat -PdesktopOnly=true -PdesktopAllowUnconfiguredStandardServices=true :desktopApp:packageReleaseMsix -x test
```
Copy the newest generated MSIX to your desktop:
```powershell
$msix = Get-ChildItem .\desktopApp\build\compose\binaries\main-release\msix -Filter *.msix | Sort-Object LastWriteTime -Descending | Select-Object -First 1
Copy-Item -Force $msix.FullName "$env:USERPROFILE\Desktop\"
```
The MSIX task is separate from MSI packaging. It stages the release app image at
`desktopApp/build/msix/package`, packages it with Windows SDK `makeappx.exe`, and
writes the MSIX to:
```text
desktopApp/build/compose/binaries/main-release/msix
```
For Microsoft Store submission, set the package identity values from Partner
Center so `AppxManifest.xml` matches the reserved app identity:
```powershell
.\gradlew.bat `
-PdesktopOnly=true `
-PdesktopMsixIdentityName=<Partner Center package identity name> `
-PdesktopMsixPublisher=<Partner Center publisher CN> `
-PdesktopMsixPublisherDisplayName=<Publisher display name> `
:desktopApp:packageReleaseMsix -x test
```
If Windows SDK tools are not on `PATH`, pass them explicitly:
```powershell
.\gradlew.bat `
-PdesktopMakeAppxPath="C:\Program Files (x86)\Windows Kits\10\bin\<sdk-version>\x64\makeappx.exe" `
:desktopApp:packageReleaseMsix -x test
```
Local signing is optional and separate:
```powershell
.\gradlew.bat `
-PdesktopMsixCertificatePath=C:\path\to\certificate.pfx `
-PdesktopMsixCertificatePassword=<password> `
:desktopApp:signReleaseMsix -x test
```
Recommended VM split:
- Ubuntu: `./gradlew :desktopApp:packageDeb -x test`
- Fedora: `./gradlew :desktopApp:packageRpm -x test`
- Arch: `./gradlew :desktopApp:packageAur -x test`
Desktop-only Gradle invocations automatically skip the Android app module and the
Android target in `:shared`, so desktop packaging does not require `sdk.dir`,
Android SDK installation, or Android release signing values. You can force that
mode for unusual command shapes with:
```bash
./gradlew -PdesktopOnly=true :desktopApp:packageDeb -x test
```
Desktop release values are centralized in `gradle.properties`:
```properties
desktopVersion=1.0.1
desktopPackageVersion=1.0.1
desktopAurPackageRelease=1
```
The AUR path is native Arch packaging. It does not wrap the `.deb` or `.rpm`.
`packageAur` first creates a Linux app tarball, then generates an AUR worktree at:
```text
desktopApp/build/aur/episteme-bin
```
On Arch, install/test the generated package with:
```bash
sudo pacman -U ~/Reader/desktopApp/build/aur/episteme-bin/*.pkg.tar.zst
episteme
```
For the OSS/offline flavor:
```bash
./gradlew :desktopApp:packageAur -PdesktopFlavor=oss -x test
sudo pacman -U ~/Reader/desktopApp/build/aur/episteme-oss-bin/*.pkg.tar.zst
episteme-oss
```
To inspect the AUR recipe manually instead:
```bash
./gradlew :desktopApp:prepareAurPackage -x test
cd ~/Reader/desktopApp/build/aur/episteme-bin
makepkg -si
```
For publish-ready AUR metadata, pass the release tarball URL:
```bash
./gradlew :desktopApp:prepareAurPackage \
-PdesktopAurSourceUrl=https://example.com/releases/episteme-1.0.1-linux-x64.tar.gz \
-x test
```
Then publish the generated `PKGBUILD` and `.SRCINFO` from the AUR directory.
The generated AUR recipes use `license=('AGPL-3.0-only')` and install the root
`LICENSE` file into `/usr/share/licenses/$pkgname/`.
## AUR repository setup
Create an account at:
```text
https://aur.archlinux.org/register/
```
Add your public SSH key in the account settings, then confirm SSH works:
```bash
ssh aur@aur.archlinux.org
```
The command should authenticate and print AUR help text. It will not open a
normal shell.
Create the package repos by cloning their not-yet-existing names:
```bash
git clone ssh://aur@aur.archlinux.org/episteme-bin.git
git clone ssh://aur@aur.archlinux.org/episteme-oss-bin.git
```
If a name already exists, inspect it first. If it is abandoned, follow the AUR
orphan/adoption process instead of creating a duplicate package name.
For each release, extract the matching `aur-<package>-<version>.tar.gz` metadata
archive from the GitHub release, copy `PKGBUILD` and `.SRCINFO` into the matching
AUR clone, then commit and push:
```bash
tar -xzf aur-episteme-bin-1.0.1.tar.gz -C episteme-bin
cd episteme-bin
git add PKGBUILD .SRCINFO
git commit -m "Update to 1.0.1"
git push
```
Repeat the same flow for `episteme-oss-bin`.
## CI release workflow
`Desktop release` in GitHub Actions builds desktop artifacts for standard and
OSS flavors:
- Windows MSI
- Ubuntu/Debian DEB
- Fedora RPM
- Linux tarball used by AUR
- Direct Arch `.pkg.tar.zst`
- AUR metadata archives containing `PKGBUILD` and `.SRCINFO`
- `SHA256SUMS.txt`
Before running it, publish Pdfium once from a machine that has the ignored
`third_party/pdfium` folders:
```powershell
.\scripts\desktop\publish-pdfium-release.ps1 `
-Repository Aryan-Raj3112/episteme `
-Tag pdfium-desktop-v1
```
That release must contain:
```text
pdfium-linux-x64-v8.zip
pdfium-win-x64-v8.zip
```
The desktop release workflow downloads those assets with:
```powershell
.\scripts\desktop\download-pdfium.ps1 -Tag pdfium-desktop-v1
```
Required GitHub Secrets for standard desktop packages:
```text
DESKTOP_FIREBASE_PROJECT_ID
DESKTOP_FIREBASE_WEB_API_KEY
DESKTOP_GOOGLE_OAUTH_CLIENT_ID
DESKTOP_GOOGLE_OAUTH_CLIENT_SECRET
```
`MYAPP_RELEASE_STORE_FILE` is not used by desktop packaging. Android is skipped
for `:desktopApp:*` tasks.
AUR publishing still needs the two AUR repos:
```text
episteme-bin
episteme-oss-bin
```
Upload the generated `PKGBUILD` and `.SRCINFO` from:
```text
aur-episteme-bin-<version>.tar.gz
aur-episteme-oss-bin-<version>.tar.gz
```

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