// SleepyOwl for the Linux desktop (docs/desktop.md): windows, desktop screens, the mpv player and system integration. // Run: ./gradlew :desktop:run, or tools/linux/desktop.sh without Gradle around the process. // Package: ./gradlew :desktop:packageDeb → desktop/build/deb/sleepyowl__amd64.deb. import java.nio.file.Files import java.nio.file.Path import java.nio.file.attribute.PosixFilePermissions import java.time.LocalDateTime import java.time.format.DateTimeFormatter plugins { alias(libs.plugins.kotlin.jvm) alias(libs.plugins.kotlin.compose) alias(libs.plugins.compose.multiplatform) } /** The desktop app's version (gradle.properties, or `-Psleepyowl.versionName=…` from CI): the About screen, the log, the .deb. */ val appVersion = providers.gradleProperty("sleepyowl.versionName").get() kotlin { jvmToolchain(21) } // BuildInfo.VERSION from appVersion: one place for the number. val buildInfo = tasks.register("buildInfo") { val version = appVersion val out = layout.buildDirectory.dir("generated/buildinfo") inputs.property("version", version) outputs.dir(out) doLast { out.get().file("BuildInfo.kt").asFile.apply { parentFile.mkdirs() }.writeText( "package ru.stenfredd.sleepyowl.desktop\n\n/** Generated by desktop/build.gradle.kts. */\nobject BuildInfo {\n const val VERSION = \"$version\"\n}\n", ) } } sourceSets.main { kotlin.srcDir(buildInfo) } dependencies { implementation(project(":ui:shared")) implementation(project(":playback:engine-mpv")) implementation(compose.desktop.currentOs) implementation(libs.koin.core) implementation(libs.koin.compose.viewmodel) implementation(libs.coil.compose) implementation(libs.coil.network.okhttp) implementation(libs.kotlinx.coroutines.swing) implementation(libs.jna) implementation(libs.dbus.java.core) implementation(libs.dbus.java.unixsocket) testImplementation(libs.junit) testImplementation(libs.koin.test) } // XToolkit: the windows' WM_CLASS (sun.awt.X11.XToolkit.awtAppClassName), set before the first window. val awtOpens = listOf("--add-opens=java.desktop/sun.awt.X11=ALL-UNNAMED", "--add-opens=java.desktop/sun.awt=ALL-UNNAMED") compose.desktop { application { mainClass = "ru.stenfredd.sleepyowl.desktop.MainKt" jvmArgs += awtOpens nativeDistributions { // jpackage of JDK 21 with a jlink'd JRE inside: createDistributable → build/compose/binaries/main/app/sleepyowl. // The .deb around it is ours (packageDeb below): jpackage's own one can't depend on libmpv2. javaHome = javaToolchains.launcherFor { languageVersion = JavaLanguageVersion.of(21) }.get() .metadata.installationPath.asFile.absolutePath packageName = "sleepyowl" packageVersion = appVersion description = "Jellyfin client" vendor = "StenFredd" // suggestRuntimeModules gives java.instrument, jdk.security.auth (dbus-java), jdk.unsupported; jdeps can't see // TLS (jdk.crypto.ec: the public address over https) or locale data (jdk.localedata: track languages in Russian). modules("java.instrument", "jdk.security.auth", "jdk.unsupported", "jdk.crypto.ec", "jdk.localedata") linux { iconFile = file("src/main/resources/icons/sleepyowl-256.png") } } } } // Licence texts of "О приложении": the TV's (app/src/main/assets/licenses) next to the desktop's own in resources/licenses. tasks.processResources { from(rootProject.file("app/src/main/assets/licenses")) { into("licenses") } } tasks.withType().configureEach { jvmArgs(awtOpens) } // Classpath into a file: tools/linux/desktop.sh starts the app without Gradle. tasks.register("desktopClasspath") { val cp = sourceSets.main.get().runtimeClasspath val out = layout.buildDirectory.file("desktop.classpath") inputs.files(cp) outputs.file(out) doLast { out.get().asFile.writeText(cp.joinToString(":")) } } /** * The .deb: jpackage's app image in /opt/sleepyowl, `sleepyowl` in /usr/bin, the menu entry and the icons. The Debian * version gets the build time (0.1.0-202610271530), so `sudo apt install ./sleepyowl_*.deb` always takes a new build as * an update. Menus and icon caches refresh by dpkg's triggers (desktop-file-utils, hicolor-icon-theme): no scripts. */ abstract class PackageDeb : DefaultTask() { @get:InputDirectory abstract val appImage: DirectoryProperty @get:InputFile abstract val desktopFile: RegularFileProperty @get:InputDirectory abstract val icons: DirectoryProperty @get:Input abstract val version: Property @get:Input abstract val maintainer: Property @get:Input abstract val depends: ListProperty @get:OutputDirectory abstract val outDir: DirectoryProperty @get:Inject abstract val exec: ExecOperations @get:Inject abstract val fs: FileSystemOperations @TaskAction fun build() { val debVersion = "${version.get()}-" + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddHHmm")) val root = temporaryDir.resolve("root") fs.delete { delete(root) } fs.copy { from(appImage); into(root.resolve("opt/sleepyowl")) } root.resolve("usr/bin").mkdirs() Files.createSymbolicLink(root.resolve("usr/bin/sleepyowl").toPath(), Path.of("../../opt/sleepyowl/bin/sleepyowl")) fs.copy { from(desktopFile); into(root.resolve("usr/share/applications")) } icons.get().asFile.listFiles()!!.forEach { png -> val size = Regex("sleepyowl-(\\d+)\\.png").matchEntire(png.name)?.groupValues?.get(1) ?: return@forEach fs.copy { from(png); into(root.resolve("usr/share/icons/hicolor/${size}x$size/apps")); rename { "sleepyowl.png" } } } // Debian's modes whatever the umask: 755 for directories and programs, 644 for the rest. root.walkTopDown().filter { !Files.isSymbolicLink(it.toPath()) }.forEach { Files.setPosixFilePermissions(it.toPath(), PosixFilePermissions.fromString(if (it.isDirectory || it.canExecute()) "rwxr-xr-x" else "rw-r--r--")) } val kb = root.walkTopDown().filter { it.isFile }.sumOf { it.length() } / 1024 root.resolve("DEBIAN").mkdirs() root.resolve("DEBIAN/control").writeText( """ Package: sleepyowl Version: $debVersion Architecture: amd64 Maintainer: ${maintainer.get()} Installed-Size: $kb Depends: ${depends.get().joinToString(", ")} Section: video Priority: optional Description: Jellyfin client for movies and series SleepyOwl plays movies and series from a Jellyfin server with mpv, in a window, full screen or in a small always-on-top window. """.trimIndent() + "\n", ) val out = outDir.get().asFile.apply { listFiles()?.forEach { it.delete() } }.resolve("sleepyowl_${debVersion}_amd64.deb") exec.exec { commandLine("dpkg-deb", "--root-owner-group", "-Zxz", "--build", root, out) } logger.lifecycle("Built $out") } } tasks.register("packageDeb") { group = "distribution" dependsOn("createDistributable") appImage = layout.buildDirectory.dir("compose/binaries/main/app/sleepyowl") desktopFile = layout.projectDirectory.file("packaging/sleepyowl.desktop") icons = layout.projectDirectory.dir("src/main/resources/icons") version = appVersion maintainer = "StenFredd " // libmpv2, libEGL and libOpenGL are loaded through JNA, so nothing finds them on its own; the rest is what dpkg-shlibdeps gave // for the JRE's, Skiko's and JNA's libraries in L10 (Mint 22 / Ubuntu 24.04 names). depends = listOf( "libmpv2 (>= 0.37)", "libegl1", "libopengl0", "libgl1", "libc6 (>= 2.34)", "libstdc++6", "libfontconfig1 (>= 2.12.6)", "libx11-6", "libxext6", "libxi6", "libxrender1", "libxtst6", "libasound2t64 | libasound2", ) outDir = layout.buildDirectory.dir("deb") }