Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -310,7 +310,6 @@ jobs:
- "tools/repackage-msi-with-uninstall-cleanup.ps1"
- "tools/verify-windows-package.ps1"
- "ui/build.gradle.kts"
- "ui/desktop-libvlc.gradle.kts"
- "release/third-party/libvlc/**"
- "ui/src/jvmMain/**"
- "ui/src/jvmTest/**"
Expand Down Expand Up @@ -447,7 +446,6 @@ jobs:
- "ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/MacOsKeychain*.kt"
- "ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopMacOsLoginPersistenceTest.kt"
- "ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopSecretStoreTest.kt"
- "ui/desktop-libvlc.gradle.kts"
- "release/third-party/libvlc/**"
- "ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/MacOsSystemMediaControls.kt"
- "ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopSystemMediaControls.kt"
Expand Down
4 changes: 4 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,10 @@ cargo test --locked # Rust compiler and schema
Focused runs are faster while you iterate, for example
`./gradlew :ui:desktopTest --tests '*LoginAttemptStateTest'`.

The `:ui` test tasks log the name of any test still running after 3 minutes
and stop after 30 minutes, so a stuck test fails the build with its name
instead of running until the CI job limit.

### Run repository checks

```bash
Expand Down
19 changes: 16 additions & 3 deletions PLATFORMS.md
Original file line number Diff line number Diff line change
Expand Up @@ -396,7 +396,7 @@ guarantees, not claims about a published installer; the code may have changed.

### Desktop video playback

**Last reviewed: 2026-10-17.** These are source, deterministic-test, and local
**Last reviewed: 2026-10-19.** These are source, deterministic-test, and local
build observations, not claims about a published installer; the code and
packaging may have changed.

Expand All @@ -412,9 +412,22 @@ packaging may have changed.
and L seek 10 seconds, and M mutes. Arrow keys keep moving between media
items.
- libVLC is found once per process (`DesktopLibVlcRuntime.kt`). A copy bundled
in the package is preferred over a system VLC. Windows and macOS packages
in the package is preferred over a system VLC. Discovery uses vlcj's
candidate directories and order (the bundled copy, a vlcj configuration
file, the JNA library path, the application directory, VLC's install or
well-known library directories, and PATH) but checks each directory itself
and never searches below it, so a machine without libVLC settles quickly
instead of scanning whole system trees. On Linux the well-known directories
are the JVM's own Debian multiarch directory (for example
`/usr/lib/aarch64-linux-gnu`) first, then vlcj's `/usr/lib/x86_64-linux-gnu`,
`/usr/lib64`, `/usr/local/lib64`, `/usr/lib/i386-linux-gnu`, `/usr/lib`, and
`/usr/local/lib`; the remaining Debian triplet directories come after PATH.
Versioned runtime names such as `libvlc.so.5` are accepted, and vlcj sets
the plugin path to `<directory>/plugins` or `<directory>/vlc/plugins`
unless `VLC_PLUGIN_PATH` is already set.
`DesktopLibVlcDiscoveryTest` covers this. Windows and macOS packages
stage VideoLAN's VLC 3.0.24 release libraries and playback plugins, pinned
by SHA-256 in `ui/desktop-libvlc.gradle.kts`, with libVLC's LGPL and GPL texts and a
by SHA-256 in `ui/build.gradle.kts`, with libVLC's LGPL and GPL texts and a
source link in the packaged `libvlc` folder. Linux packages do not bundle
libVLC: the DEB recommends `libvlc5` and `vlc-plugin-base`, and the RPM
recommends any package that provides `libvlc.so.5`. Without libVLC the
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
category: fix
issue: none
pull: 520
platforms: linux
user-facing: yes

On a Linux desktop without libVLC, the media viewer offers its "Play video" button for the default video app right away instead of after a long search of system folders.
7 changes: 7 additions & 0 deletions changes/unreleased/520-ui-test-timeouts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
category: internal
issue: none
pull: 520
platforms: desktop
user-facing: no

Desktop test tasks now log the name of any test still running after 3 minutes and stop after 30 minutes, so a stuck test fails the build with its name instead of running until the CI job limit.
5 changes: 3 additions & 2 deletions release/third-party/libvlc/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@

`COPYING` (GNU GPL 2) and `COPYING.LIB` (GNU LGPL 2.1) are copied unchanged
from the VLC `3.0.24` source tag. VideoLAN's Windows and macOS binary archives
do not include `COPYING.LIB`, so `ui/desktop-libvlc.gradle.kts` stages these
files next to the bundled libVLC in desktop packages.
do not include `COPYING.LIB`, so the `prepareDesktopLibVlc` task in
`ui/build.gradle.kts` stages these files next to the bundled libVLC in desktop
packages.

Update both files from the matching source tag whenever the pinned libVLC
version changes.
249 changes: 247 additions & 2 deletions ui/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,9 +1,22 @@
import org.gradle.api.tasks.testing.Test
import org.gradle.api.tasks.testing.TestDescriptor
import org.gradle.api.tasks.testing.TestListener
import org.gradle.api.tasks.testing.TestResult
import org.gradle.api.tasks.testing.logging.TestExceptionFormat
import org.jetbrains.compose.desktop.application.dsl.TargetFormat
import java.io.File
import java.net.URI
import java.nio.file.Files
import java.nio.file.StandardCopyOption
import java.security.MessageDigest
import java.time.Duration
import java.time.LocalDate
import java.time.ZoneOffset
import java.util.Base64
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.ScheduledFuture
import java.util.concurrent.ScheduledThreadPoolExecutor
import java.util.concurrent.TimeUnit

val desktopArchitecture = System.getProperty("os.arch").lowercase()
val ncDesktopPackageVersion = providers.gradleProperty("ncDesktopPackageVersion").get()
Expand Down Expand Up @@ -158,13 +171,245 @@ tasks.configureEach {
}
}

apply(from = "desktop-libvlc.gradle.kts")
// libVLC for in-app desktop video playback.
//
// Windows and macOS packages carry VideoLAN's official VLC 3 release libraries, pinned by
// SHA-256 below. Linux packages instead recommend the distribution's libVLC, which vlcj finds
// at runtime. Only packaging and `run` tasks need these files; tests never download them.
//
// This stays in the module build script: Android lint (lintAnalyzeRelease, lint 9.3.1) also
// visits a Kotlin script applied with apply(from = ...) and crashes analysing it.

val desktopLibVlcVersion = "3.0.24"

data class DesktopLibVlcArchive(val url: String, val sha256: String, val platform: String) {
val fileName: String get() = url.substringAfterLast('/')

/** The same file on VideoLAN's own server, used when mirrors do not match the pin. */
val originUrl: String
get() = url.replace("https://get.videolan.org/vlc/", "https://download.videolan.org/pub/videolan/vlc/")
}

val desktopLibVlcHostArchitecture = System.getProperty("os.arch").lowercase()
val desktopLibVlcArchive: DesktopLibVlcArchive? = System.getProperty("os.name").let { os ->
val base = "https://get.videolan.org/vlc/$desktopLibVlcVersion"
when {
os.startsWith("Windows", ignoreCase = true) -> DesktopLibVlcArchive(
url = "$base/win64/vlc-$desktopLibVlcVersion-win64.zip",
sha256 = "fcf30850371ad10c9373cc4f0f4501e7dee49e3e9ae9f20c72fb2661a1ca6323",
platform = "windows",
)
os.startsWith("Mac", ignoreCase = true) &&
desktopLibVlcHostArchitecture in setOf("aarch64", "arm64") -> DesktopLibVlcArchive(
url = "$base/macosx/vlc-$desktopLibVlcVersion-arm64.dmg",
sha256 = "64a89d93cdd30b0e97131743e246373db82d6826ea882d265d46d74b136da2b7",
platform = "macos",
)
os.startsWith("Mac", ignoreCase = true) -> DesktopLibVlcArchive(
url = "$base/macosx/vlc-$desktopLibVlcVersion-intel64.dmg",
sha256 = "1ef6c903e2dc026d4e58ddf7b2a9ed0eb6f8caf80a9a9f46f96490340b962707",
platform = "macos",
)
else -> null
}
}

// Plugin groups that only serve VLC's own interfaces, scripting, or streaming output.
val desktopLibVlcExcludedPluginGroups = listOf(
"access_output", "control", "gui", "lua", "mux", "services_discovery", "stream_out",
"video_splitter", "visualization",
)

val desktopLibVlcDownloadDirectory = layout.buildDirectory.dir("libvlc/download")
val DESKTOP_LIBVLC_CONNECT_TIMEOUT_MILLIS = 30_000
val DESKTOP_LIBVLC_READ_TIMEOUT_MILLIS = 60_000
val desktopLibVlcLicenseDirectory = rootProject.layout.projectDirectory.dir("release/third-party/libvlc")
val desktopAppResourcesDirectory = layout.buildDirectory.dir("generated/desktop-app-resources")

fun File.sha256(): String {
val digest = MessageDigest.getInstance("SHA-256")
inputStream().use { input ->
val buffer = ByteArray(1 shl 16)
while (true) {
val read = input.read(buffer)
if (read < 0) break
digest.update(buffer, 0, read)
}
}
return digest.digest().joinToString("") { "%02x".format(it) }
}

val downloadDesktopLibVlc by tasks.registering {
description = "Downloads and verifies the pinned VideoLAN libVLC release for this desktop OS."
val archive = desktopLibVlcArchive
onlyIf { archive != null }
if (archive != null) {
inputs.property("url", archive.url)
inputs.property("sha256", archive.sha256)
outputs.file(desktopLibVlcDownloadDirectory.map { it.file(archive.fileName) })
}
doLast {
val pinned = checkNotNull(archive)
val target = desktopLibVlcDownloadDirectory.get().file(pinned.fileName).asFile
if (target.isFile && target.sha256() == pinned.sha256) return@doLast
target.parentFile.mkdirs()
val partial = File(target.parentFile, "${pinned.fileName}.partial")
// get.videolan.org redirects to a random mirror, and a mirror can serve a stale or broken
// file. Only bytes matching the pin are accepted; after two mirror attempts the VideoLAN
// origin server is used.
val sources = listOf(pinned.url, pinned.url, pinned.originUrl)
val rejected = mutableListOf<String>()
for ((index, source) in sources.withIndex()) {
try {
// Finite timeouts reject a mirror that accepts the connection and then stalls.
val connection = URI.create(source).toURL().openConnection().apply {
connectTimeout = DESKTOP_LIBVLC_CONNECT_TIMEOUT_MILLIS
readTimeout = DESKTOP_LIBVLC_READ_TIMEOUT_MILLIS
}
connection.getInputStream().use { input ->
Files.copy(input, partial.toPath(), StandardCopyOption.REPLACE_EXISTING)
}
} catch (failure: java.io.IOException) {
rejected += "$source: ${failure.javaClass.simpleName}"
Thread.sleep(2_000L * (index + 1))
continue
}
val actual = partial.sha256()
if (actual == pinned.sha256) {
Files.move(partial.toPath(), target.toPath(), StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE)
return@doLast
}
rejected += "$source: SHA-256 $actual (${partial.length()} bytes)"
partial.delete()
}
throw GradleException(
"No source served ${pinned.fileName} with SHA-256 ${pinned.sha256}:\n" + rejected.joinToString("\n"),
)
}
}

val prepareDesktopLibVlc by tasks.registering {
description = "Stages libVLC and its playback plugins as desktop application resources."
dependsOn(downloadDesktopLibVlc)
val archive = desktopLibVlcArchive
if (archive != null) {
inputs.file(desktopLibVlcDownloadDirectory.map { it.file(archive.fileName) })
inputs.property("excludedPluginGroups", desktopLibVlcExcludedPluginGroups)
inputs.dir(desktopLibVlcLicenseDirectory)
}
outputs.dir(desktopAppResourcesDirectory)
doLast {
val resources = desktopAppResourcesDirectory.get().asFile
resources.deleteRecursively()
// Compose packages `common` on every OS; it stays empty but must exist.
File(resources, "common").mkdirs()
val pinned = archive ?: return@doLast
val output = File(resources, "${pinned.platform}/libvlc")
val downloaded = desktopLibVlcDownloadDirectory.get().file(pinned.fileName).asFile
when (pinned.platform) {
"windows" -> {
val root = "vlc-$desktopLibVlcVersion"
copy {
from(zipTree(downloaded)) {
include("$root/libvlc.dll", "$root/libvlccore.dll", "$root/plugins/**")
desktopLibVlcExcludedPluginGroups.forEach { exclude("$root/plugins/$it/**") }
// Blu-ray menu jars would otherwise join the application class path.
exclude("**/*.jar")
eachFile { relativePath = RelativePath(true, *relativePath.segments.drop(1).toTypedArray()) }
}
includeEmptyDirs = false
into(output)
}
}
"macos" -> {
val mount = File(temporaryDir, "vlc-mount")
mount.deleteRecursively()
mount.mkdirs()
providers.exec {
commandLine("hdiutil", "attach", "-nobrowse", "-readonly", "-mountpoint", mount.absolutePath, downloaded.absolutePath)
}.result.get().assertNormalExitValue()
try {
val macOs = File(mount, "VLC.app/Contents/MacOS")
copy {
from(File(macOs, "lib")) { into("lib") }
// macOS plugins are one flat directory, so all of them are kept.
from(File(macOs, "plugins")) {
into("plugins")
exclude("**/*.jar")
}
into(output)
}
} finally {
providers.exec { commandLine("hdiutil", "detach", mount.absolutePath) }.result.get()
}
}
}
check(File(output, if (pinned.platform == "windows") "libvlc.dll" else "lib/libvlc.dylib").isFile) {
"The libVLC archive did not contain the expected library."
}
check(File(output, "plugins").isDirectory) { "The libVLC archive did not contain its plugins." }
// VideoLAN's binary archives omit COPYING.LIB, so both texts come from the 3.0.24 source tag.
copy {
from(desktopLibVlcLicenseDirectory) { include("COPYING", "COPYING.LIB") }
into(output)
}
check(File(output, "COPYING.LIB").isFile && File(output, "COPYING").isFile) {
"The libVLC license texts were not staged."
}
File(output, "README.txt").writeText(
listOf(
"libVLC $desktopLibVlcVersion from VideoLAN, used by nati.ve for in-app video playback.",
"License: libVLC is under the GNU Lesser General Public License 2.1 or later (COPYING.LIB).",
"Some VLC plugins are under the GNU General Public License 2 or later (COPYING).",
"Release binaries: ${pinned.url}",
"Corresponding source: https://get.videolan.org/vlc/$desktopLibVlcVersion/vlc-$desktopLibVlcVersion.tar.xz",
).joinToString(separator = "\n", postfix = "\n"),
)
}
}

/**
* Names a test that is still running after [threshold]. Only failures are logged, so a hung test
* otherwise prints nothing until the task timeout or the CI job limit ends the build.
*/
class LongRunningTestReporter(private val taskLogger: Logger, private val threshold: Duration) : TestListener {
private val scheduler = ScheduledThreadPoolExecutor(1) { task ->
Thread(task, "long-running-test-reporter").apply { isDaemon = true }
}.apply {
// The thread exits while no test runs, so no idle thread outlives the build.
setKeepAliveTime(10, TimeUnit.SECONDS)
allowCoreThreadTimeOut(true)
removeOnCancelPolicy = true
}
private val pending = ConcurrentHashMap<TestDescriptor, ScheduledFuture<*>>()

override fun beforeTest(testDescriptor: TestDescriptor) {
pending[testDescriptor] = scheduler.schedule({
taskLogger.lifecycle(
"Test still running after ${threshold.toMinutes()} minutes: " +
"${testDescriptor.className} > ${testDescriptor.displayName}",
)
}, threshold.toMillis(), TimeUnit.MILLISECONDS)
}

override fun afterTest(testDescriptor: TestDescriptor, result: TestResult) {
pending.remove(testDescriptor)?.cancel(false)
}

override fun beforeSuite(suite: TestDescriptor) = Unit

override fun afterSuite(suite: TestDescriptor, result: TestResult) = Unit
}

tasks.withType<Test>().configureEach {
testLogging {
events("failed")
exceptionFormat = TestExceptionFormat.FULL
}
// A full desktop run takes about five minutes on CI. Stop a stuck run well inside the CI job
// limit, after naming the test that is stuck.
timeout.set(Duration.ofMinutes(30))
addTestListener(LongRunningTestReporter(logger, Duration.ofMinutes(3)))
// Scene tests must not depend on whether the build machine has VLC installed.
systemProperty("dev.obiente.nextcloudnative.libvlc", "disabled")
// Nor may they register with the host's system media controls.
Expand Down Expand Up @@ -264,7 +509,7 @@ compose.desktop {

nativeDistributions {
modules("jdk.security.auth")
// libVLC for in-app video on Windows and macOS; see desktop-libvlc.gradle.kts.
// libVLC for in-app video on Windows and macOS; see prepareDesktopLibVlc above.
appResourcesRootDir.set(layout.buildDirectory.dir("generated/desktop-app-resources"))
targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb, TargetFormat.Rpm)
// Preserve Windows/Linux launcher paths used by existing integrations.
Expand Down
Loading
Loading