diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7573b7a87..edf26019a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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/**" @@ -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" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5f0c631ce..5e4ff7870 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 diff --git a/PLATFORMS.md b/PLATFORMS.md index 4f49e0ee8..4812f8df2 100644 --- a/PLATFORMS.md +++ b/PLATFORMS.md @@ -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. @@ -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 `/plugins` or `/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 diff --git a/changes/unreleased/520-desktop-video-fallback-without-libvlc.md b/changes/unreleased/520-desktop-video-fallback-without-libvlc.md new file mode 100644 index 000000000..93c473b8c --- /dev/null +++ b/changes/unreleased/520-desktop-video-fallback-without-libvlc.md @@ -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. diff --git a/changes/unreleased/520-ui-test-timeouts.md b/changes/unreleased/520-ui-test-timeouts.md new file mode 100644 index 000000000..9c3d740b0 --- /dev/null +++ b/changes/unreleased/520-ui-test-timeouts.md @@ -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. diff --git a/release/third-party/libvlc/README.md b/release/third-party/libvlc/README.md index 9656033e8..8a629c81a 100644 --- a/release/third-party/libvlc/README.md +++ b/release/third-party/libvlc/README.md @@ -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. diff --git a/ui/build.gradle.kts b/ui/build.gradle.kts index 781d912df..24a6714a5 100644 --- a/ui/build.gradle.kts +++ b/ui/build.gradle.kts @@ -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() @@ -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() + 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>() + + 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().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. @@ -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. diff --git a/ui/desktop-libvlc.gradle.kts b/ui/desktop-libvlc.gradle.kts deleted file mode 100644 index 7a54014cc..000000000 --- a/ui/desktop-libvlc.gradle.kts +++ /dev/null @@ -1,199 +0,0 @@ -// 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. - -import java.io.File -import java.net.URI -import java.nio.file.Files -import java.nio.file.StandardCopyOption -import java.security.MessageDigest - -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() - 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"), - ) - } -} diff --git a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopLibVlcRuntime.kt b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopLibVlcRuntime.kt index ab8fa7d99..f5d9d00c8 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopLibVlcRuntime.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopLibVlcRuntime.kt @@ -1,12 +1,19 @@ package dev.obiente.nextcloudnative.app import androidx.compose.runtime.mutableStateOf +import java.io.File +import java.io.FileFilter import java.nio.file.Files import java.util.concurrent.atomic.AtomicBoolean import java.nio.file.Path import uk.co.caprica.vlcj.factory.MediaPlayerFactory import uk.co.caprica.vlcj.factory.discovery.NativeDiscovery +import uk.co.caprica.vlcj.factory.discovery.provider.DirectoryProviderDiscoveryStrategy import uk.co.caprica.vlcj.factory.discovery.provider.DiscoveryDirectoryProvider +import uk.co.caprica.vlcj.factory.discovery.strategy.LinuxNativeDiscoveryStrategy +import uk.co.caprica.vlcj.factory.discovery.strategy.NativeDiscoveryStrategy +import uk.co.caprica.vlcj.factory.discovery.strategy.OsxNativeDiscoveryStrategy +import uk.co.caprica.vlcj.factory.discovery.strategy.WindowsNativeDiscoveryStrategy /** * Finds libVLC once per process and owns the shared player factory. @@ -26,8 +33,8 @@ internal class DesktopLibVlcRuntime(private val enabled: Boolean) { val libraryDirectory: String? by lazy(LazyThreadSafetyMode.SYNCHRONIZED) { if (!enabled) return@lazy null try { - val discovery = NativeDiscovery() - // A library already on the default search path reports no directory. + val discovery = NativeDiscovery(*desktopLibVlcDiscoveryStrategies()) + // vlcj 4 has no default-search-path fallback: success always reports a directory. if (discovery.discover()) discovery.discoveredPath() ?: "" else null } catch (_: LinkageError) { null @@ -108,6 +115,104 @@ class DesktopBundledLibVlcDirectoryProvider : DiscoveryDirectoryProvider { ) } +/** + * vlcj's own strategies, with each candidate directory checked directly. + * + * vlcj searches every candidate recursively and follows symbolic links, so without libVLC it walks + * all of /usr/lib, /usr/local/lib, and every PATH entry. That takes minutes or longer, and the + * viewer hides its external-player fallback until discovery ends. The candidates, their order, and + * the per-OS loading and plugin path handling stay vlcj's; only the subdirectory search is gone. + * Linux also checks the Debian multiarch directories that the recursive search used to reach. + */ +internal fun desktopLibVlcDiscoveryStrategies( + osArch: String = System.getProperty("os.arch").orEmpty(), +): Array { + val linux = LinuxNativeDiscoveryStrategy() + return arrayOf( + DesktopLibVlcDirectDiscoveryStrategy(linux, LINUX_LIBVLC_NAMES) { + linuxLibVlcCandidates(linux.discoveryDirectories(), osArch) + }, + DesktopLibVlcDirectDiscoveryStrategy( + OsxNativeDiscoveryStrategy(), + listOf(Regex("libvlc\\.dylib"), Regex("libvlccore\\.dylib")), + ), + DesktopLibVlcDirectDiscoveryStrategy( + WindowsNativeDiscoveryStrategy(), + listOf(Regex("libvlc\\.dll"), Regex("libvlccore\\.dll")), + ), + ) +} + +/** vlcj's Linux names, which accept the versioned runtime files such as `libvlc.so.5`. */ +internal val LINUX_LIBVLC_NAMES = listOf(Regex("libvlc\\.so(?:\\.\\d)*"), Regex("libvlccore\\.so(?:\\.\\d)*")) + +/** + * vlcj's Linux candidates with Debian multiarch directories added. + * + * vlcj names only the x86_64 and i386 triplet directories, so on other architectures the distro + * libVLC in `/usr/lib/` was found only by the recursive search. The JVM's own triplet goes + * before vlcj's first system directory. vlcj stops at the first directory holding libVLC even when + * that copy cannot load, so the other triplets go last, where they never hide a loadable copy. + */ +internal fun linuxLibVlcCandidates( + vlcjCandidates: List, + osArch: String, + usrDirectory: String = "/usr", +): List { + val native = debianMultiarchTriplet(osArch)?.let { "$usrDirectory/lib/$it" } + val others = DEBIAN_MULTIARCH_TRIPLETS.map { "$usrDirectory/lib/$it" } + val firstSystem = vlcjCandidates.indexOfFirst { it.startsWith("$usrDirectory/") } + .takeIf { it >= 0 } ?: vlcjCandidates.size + return (vlcjCandidates.take(firstSystem) + listOfNotNull(native) + vlcjCandidates.drop(firstSystem) + others) + .distinct() +} + +/** The Debian multiarch triplet for a JVM `os.arch` value, or null when it is not a known one. */ +internal fun debianMultiarchTriplet(osArch: String): String? = when (osArch.lowercase()) { + "amd64", "x86_64" -> "x86_64-linux-gnu" + "aarch64", "arm64" -> "aarch64-linux-gnu" + "arm", "armv7l", "armhf" -> "arm-linux-gnueabihf" + "x86", "i386", "i486", "i586", "i686" -> "i386-linux-gnu" + "riscv64" -> "riscv64-linux-gnu" + "ppc64le" -> "powerpc64le-linux-gnu" + "s390x" -> "s390x-linux-gnu" + else -> null +} + +private val DEBIAN_MULTIARCH_TRIPLETS = listOf( + "x86_64-linux-gnu", + "aarch64-linux-gnu", + "arm-linux-gnueabihf", + "i386-linux-gnu", + "riscv64-linux-gnu", + "powerpc64le-linux-gnu", + "s390x-linux-gnu", +) + +internal class DesktopLibVlcDirectDiscoveryStrategy( + private val delegate: DirectoryProviderDiscoveryStrategy, + private val libraryNames: List, + private val candidates: () -> List = delegate::discoveryDirectories, +) : NativeDiscoveryStrategy { + override fun supported(): Boolean = delegate.supported() + + override fun discover(): String? = firstDirectoryContainingLibraries(candidates(), libraryNames) + + override fun onFound(path: String): Boolean = delegate.onFound(path) + + override fun onSetPluginPath(path: String): Boolean = delegate.onSetPluginPath(path) +} + +/** The first of [directories] that itself holds a file matching each of [libraryNames]; subdirectories are never searched. */ +internal fun firstDirectoryContainingLibraries(directories: List, libraryNames: List): String? = + directories.asSequence() + .map(::File) + .firstOrNull { directory -> + val names = directory.listFiles(FileFilter { it.isFile })?.map(File::getName).orEmpty() + libraryNames.all { pattern -> names.any(pattern::matches) } + } + ?.absolutePath + /** * The directory holding the bundled libVLC library, or null when this package carries none. * Windows keeps `libvlc.dll` beside `plugins`; macOS keeps `lib/libvlc.dylib` beside `plugins`. diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopLibVlcDiscoveryTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopLibVlcDiscoveryTest.kt new file mode 100644 index 000000000..9b17a3adb --- /dev/null +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopLibVlcDiscoveryTest.kt @@ -0,0 +1,172 @@ +package dev.obiente.nextcloudnative.app + +import java.nio.file.Files +import java.nio.file.Path +import kotlin.io.path.createDirectories +import kotlin.io.path.createFile +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import org.junit.Assume.assumeTrue +import uk.co.caprica.vlcj.factory.discovery.strategy.LinuxNativeDiscoveryStrategy + +/** + * libVLC discovery checks each candidate directory itself and never walks below it, so a machine + * without libVLC settles quickly instead of scanning whole system trees. + */ +class DesktopLibVlcDiscoveryTest { + private val root: Path = Files.createTempDirectory("nc-libvlc-discovery") + private val linuxNames = LINUX_LIBVLC_NAMES + + @AfterTest + fun deleteFixture() { + root.toFile().deleteRecursively() + } + + @Test + fun theFirstCandidateHoldingBothLibrariesWinsAndSubdirectoriesAreNeverSearched() { + val nested = directory("nested").also { library(it.resolve("vlc/lib"), "libvlc.so.5", "libvlccore.so.9") } + val direct = library(directory("direct"), "libvlc.so.5", "libvlccore.so.9") + val later = library(directory("later"), "libvlc.so", "libvlccore.so") + + assertEquals( + direct.toFile().absolutePath, + firstDirectoryContainingLibraries(listOf(nested, direct, later).map(Path::toString), linuxNames), + ) + assertNull( + firstDirectoryContainingLibraries(listOf(nested.toString()), linuxNames), + "A library below a candidate is not found by walking into it.", + ) + } + + @Test + fun incompleteMissingAndNonDirectoryCandidatesAreSkipped() { + val onlyLibVlc = library(directory("only-libvlc"), "libvlc.so.5") + val misnamed = library(directory("misnamed"), "libvlc.so.5.bak", "libvlccore.so.9") + val coreAsDirectory = library(directory("core-as-directory"), "libvlc.so.5") + .also { it.resolve("libvlccore.so.9").createDirectories() } + val plainFile = root.resolve("plain-file").createFile() + val missing = root.resolve("missing") + + assertNull( + firstDirectoryContainingLibraries( + listOf(onlyLibVlc, misnamed, coreAsDirectory, plainFile, missing).map(Path::toString) + "", + linuxNames, + ), + ) + } + + @Test + fun aSymbolicLinkLoopIsNotFollowed() { + val looping = directory("looping") + try { + Files.createSymbolicLink(looping.resolve("again"), looping) + } catch (failure: Exception) { + assumeTrue("This host cannot create directory symbolic links: $failure", false) + } + + assertNull(firstDirectoryContainingLibraries(listOf(looping.toString()), linuxNames)) + } + + @Test + fun theStrategyReportsTheDirectCandidateAndKeepsVlcjsPlatformSupport() { + val direct = library(directory("direct"), "libvlc.so.5", "libvlccore.so.9") + val delegate = LinuxNativeDiscoveryStrategy() + val strategy = DesktopLibVlcDirectDiscoveryStrategy(delegate, linuxNames) { + listOf(directory("empty").toString(), direct.toString()) + } + + assertEquals(direct.toFile().absolutePath, strategy.discover()) + assertEquals(delegate.supported(), strategy.supported()) + assertEquals( + 1, + desktopLibVlcDiscoveryStrategies().count { it.supported() }, + "Exactly one platform strategy applies on any desktop host.", + ) + } + + @Test + fun aRuntimeOnlyMultiarchInstallOnAnotherArchitectureIsFound() { + val usr = root.resolve("usr").toString() + val arm = library(root.resolve("usr/lib/aarch64-linux-gnu"), "libvlc.so.5", "libvlccore.so.9") + .also { it.resolve("vlc/plugins").createDirectories() } + val linux = LinuxNativeDiscoveryStrategy() + val strategy = DesktopLibVlcDirectDiscoveryStrategy(linux, linuxNames) { + linuxLibVlcCandidates(vlcjLinuxCandidates(usr), "aarch64", usr) + } + + assertEquals(arm.toFile().absolutePath, strategy.discover(), "No -dev symlinks are needed.") + } + + @Test + fun theJvmsOwnTripletIsPreferredOverAnotherArchitecturesCopy() { + val usr = root.resolve("usr").toString() + val amd = library(root.resolve("usr/lib/x86_64-linux-gnu"), "libvlc.so.5", "libvlccore.so.9") + val arm = library(root.resolve("usr/lib/aarch64-linux-gnu"), "libvlc.so.5", "libvlccore.so.9") + + for ((osArch, expected) in listOf("aarch64" to arm, "amd64" to amd)) { + assertEquals( + expected.toFile().absolutePath, + firstDirectoryContainingLibraries( + linuxLibVlcCandidates(vlcjLinuxCandidates(usr), osArch, usr), + linuxNames, + ), + osArch, + ) + } + } + + @Test + fun multiarchCandidatesKeepVlcjsOrderAndPutOtherTripletsLast() { + val usr = "/usr" + val configured = "/opt/configured-vlc" + val candidates = linuxLibVlcCandidates(listOf(configured) + vlcjLinuxCandidates(usr) + "/usr/bin", "riscv64", usr) + + assertEquals(listOf(configured, "/usr/lib/riscv64-linux-gnu"), candidates.take(2)) + assertEquals(vlcjLinuxCandidates(usr) + "/usr/bin", candidates.drop(2).take(7)) + assertEquals( + listOf("aarch64-linux-gnu", "arm-linux-gnueabihf", "powerpc64le-linux-gnu", "s390x-linux-gnu") + .map { "/usr/lib/$it" }, + candidates.drop(9), + "Triplets vlcj already names keep their place; nothing appears twice.", + ) + assertEquals(candidates.distinct(), candidates) + assertEquals(null, debianMultiarchTriplet("loongarch64")) + assertEquals( + vlcjLinuxCandidates(usr).size + 5, + linuxLibVlcCandidates(vlcjLinuxCandidates(usr), "loongarch64", usr).size, + "An unknown architecture only appends the known triplets.", + ) + } + + @Test + fun aNonStandardCopyBelowASystemDirectoryIsStillNotWalked() { + val usr = root.resolve("usr").toString() + library(root.resolve("usr/lib/vlc-custom/lib"), "libvlc.so.5", "libvlccore.so.9") + library(root.resolve("usr/lib/x86_64-linux-gnu/nested"), "libvlc.so.5", "libvlccore.so.9") + root.resolve("usr/lib64").createDirectories() + + assertNull( + firstDirectoryContainingLibraries(linuxLibVlcCandidates(vlcjLinuxCandidates(usr), "amd64", usr), linuxNames), + ) + } + + /** vlcj 4.12.1's Linux well-known directories, in its order, below [usr]. */ + private fun vlcjLinuxCandidates(usr: String) = listOf( + "$usr/lib/x86_64-linux-gnu", + "$usr/lib64", + "$usr/local/lib64", + "$usr/lib/i386-linux-gnu", + "$usr/lib", + "$usr/local/lib", + ) + + private fun directory(name: String): Path = root.resolve(name).createDirectories() + + private fun library(directory: Path, vararg names: String): Path { + directory.createDirectories() + names.forEach { directory.resolve(it).createFile() } + return directory + } +} diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopSystemMediaControlsTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopSystemMediaControlsTest.kt index 75c459f55..d5c58bd66 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopSystemMediaControlsTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopSystemMediaControlsTest.kt @@ -14,6 +14,7 @@ import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNull import kotlin.test.assertTrue +import org.junit.Assume.assumeTrue class DesktopSystemMediaControlsTest { private val snapshot = SystemMediaSnapshot(title = "clip.mp4", playing = true, canPrevious = false, canNext = true) @@ -141,6 +142,8 @@ class DesktopSystemMediaControlsTest { /** Drives the native COM handler through its vtable with a synthetic event-args object. */ @Test fun theWindowsButtonHandlerAnswersComAndDispatchesTheButton() { + // The COM handler's callbacks use stdcall, which JNA can only create on Windows. + assumeTrue(System.getProperty("os.name").startsWith("Windows", ignoreCase = true)) val handler = WindowsSmtcButtonHandler.instance val out = PointerByReference() assertEquals(0, handler.com(0, Guid.GUID.fromString("{0557e996-7b23-5bae-aa81-ea0d671143a4}"), out)) diff --git a/website/public/screenshots/capture-manifest.json b/website/public/screenshots/capture-manifest.json index a1be74548..583bccc62 100644 --- a/website/public/screenshots/capture-manifest.json +++ b/website/public/screenshots/capture-manifest.json @@ -521,7 +521,7 @@ "gradle/wrapper/gradle-wrapper.properties": "aef287d114ce3153c3d535697a61928f5034990d570cbb2e93df549e9671483d", "settings.gradle.kts": "0acbe4b907815189abfedb2256c8659558e5a7e6995a3681a2bdfb05e335fd1a", "tools/marketing-capture-inputs.txt": "0092b8bc6445328384db348a4fd5eb173c5fe4133d170a193fa7c399dcd411ab", - "ui/build.gradle.kts": "dbf91c7d6bb9200a597bb38584220ed72018562092e2af61b5a98ef96fff90f6", + "ui/build.gradle.kts": "10b34db1e92891a248ccbbd839fe62c63010bf87e76e3d9e14445080fa708dc9", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AccountHomeWorkspaceStorage.kt": "5e0fa144df65840d15882c7da1dac19041083a302ced40578d07747f8ec7d828", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AccountPlaybackRetirement.kt": "01069d5a599bbf9dce5c9a152bb771f4bd524de7fb2d74db9c6bf9b774b31cb2", "ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/AccountPrivateMemoryCleanup.kt": "d247d7d310e8579717e243bce827e8ab6d8a8e5a6df011b481e9bc6fc72da862",