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