From 7dbe817d4dd13b80fb3ef1d2ffb842bf721cb49b Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Mon, 19 Oct 2026 07:59:05 +0200 Subject: [PATCH 1/8] fix(desktop): check libVLC candidates without a recursive search vlcj's default discovery searches every candidate directory recursively and follows symbolic links. Without libVLC it walks all of /usr/lib, /usr/local/lib, and every PATH entry. On the Linux CI runner that tree includes the Android SDK, which is the most likely reason the desktop test task stopped producing output until the 45 minute job limit after DesktopLibVlcPlaybackTest was added. The Windows desktop tests also grew by about six minutes. In the app, the same walk ran at startup and hid the external-player fallback until it finished. Keep vlcj's candidate directories, order, and per-OS loading, but check each candidate itself and never search below it. --- PLATFORMS.md | 10 +- .../app/DesktopLibVlcRuntime.kt | 56 ++++++++++- .../app/DesktopLibVlcDiscoveryTest.kt | 96 +++++++++++++++++++ 3 files changed, 159 insertions(+), 3 deletions(-) create mode 100644 ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopLibVlcDiscoveryTest.kt diff --git a/PLATFORMS.md b/PLATFORMS.md index 4f49e0ee8..458e3bba8 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,7 +412,13 @@ 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. `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 source link in the packaged `libvlc` folder. Linux packages do not bundle 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..f512859d0 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,7 +33,7 @@ internal class DesktopLibVlcRuntime(private val enabled: Boolean) { val libraryDirectory: String? by lazy(LazyThreadSafetyMode.SYNCHRONIZED) { if (!enabled) return@lazy null try { - val discovery = NativeDiscovery() + val discovery = NativeDiscovery(*desktopLibVlcDiscoveryStrategies()) // A library already on the default search path reports no directory. if (discovery.discover()) discovery.discoveredPath() ?: "" else null } catch (_: LinkageError) { @@ -108,6 +115,53 @@ 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. + */ +internal fun desktopLibVlcDiscoveryStrategies(): Array = arrayOf( + DesktopLibVlcDirectDiscoveryStrategy( + LinuxNativeDiscoveryStrategy(), + listOf(Regex("libvlc\\.so(?:\\.\\d)*"), Regex("libvlccore\\.so(?:\\.\\d)*")), + ), + DesktopLibVlcDirectDiscoveryStrategy( + OsxNativeDiscoveryStrategy(), + listOf(Regex("libvlc\\.dylib"), Regex("libvlccore\\.dylib")), + ), + DesktopLibVlcDirectDiscoveryStrategy( + WindowsNativeDiscoveryStrategy(), + listOf(Regex("libvlc\\.dll"), Regex("libvlccore\\.dll")), + ), +) + +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..c123f839e --- /dev/null +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopLibVlcDiscoveryTest.kt @@ -0,0 +1,96 @@ +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 = listOf(Regex("libvlc\\.so(?:\\.\\d)*"), Regex("libvlccore\\.so(?:\\.\\d)*")) + + @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.", + ) + } + + 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 + } +} From c667acc566e9fe4ffe2faa9195e2f82a889348b1 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Mon, 19 Oct 2026 07:54:48 +0200 Subject: [PATCH 2/8] test(ui): name long-running tests and bound the test tasks The ui test tasks log only failures, so a stuck test printed nothing until CI cancelled the job. Log the name of any test still running after 3 minutes and stop a test task after 30 minutes, well inside the CI job limit. The slowest desktop test takes about 12 seconds and the full desktop run about 6 minutes of test time. --- CONTRIBUTING.md | 4 ++ .../desktop-libvlc-bounded-discovery.md | 7 +++ ui/build.gradle.kts | 45 +++++++++++++++++++ 3 files changed, 56 insertions(+) create mode 100644 changes/unreleased/desktop-libvlc-bounded-discovery.md 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/changes/unreleased/desktop-libvlc-bounded-discovery.md b/changes/unreleased/desktop-libvlc-bounded-discovery.md new file mode 100644 index 000000000..7ee708613 --- /dev/null +++ b/changes/unreleased/desktop-libvlc-bounded-discovery.md @@ -0,0 +1,7 @@ +category: internal +issue: none +pull: none +platforms: desktop +user-facing: no + +Desktop libVLC discovery checks each candidate directory without walking its subdirectories, and desktop tests name any test running over 3 minutes and stop after 30, so a machine without libVLC no longer stalls the Linux test run. diff --git a/ui/build.gradle.kts b/ui/build.gradle.kts index 781d912df..1a61de908 100644 --- a/ui/build.gradle.kts +++ b/ui/build.gradle.kts @@ -1,9 +1,17 @@ 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.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() @@ -160,11 +168,48 @@ tasks.configureEach { apply(from = "desktop-libvlc.gradle.kts") +/** + * 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. From 61e17c316d9eaf969c723ab2ba786dafaed60449 Mon Sep 17 00:00:00 2001 From: "obiente-automations[bot]" <311907242+obiente-automations[bot]@users.noreply.github.com> Date: Mon, 19 Oct 2026 11:05:13 +0000 Subject: [PATCH 3/8] chore(website): refresh marketing captures --- website/public/screenshots/capture-manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/public/screenshots/capture-manifest.json b/website/public/screenshots/capture-manifest.json index a1be74548..67ea8f586 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": "ac59459c6765829826db40f60ddab9ca4280a96a54ddb95462d9434704007e0c", "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", From 4ea333de9798885b80d484e8e8bdc25e39e457ae Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Mon, 19 Oct 2026 14:11:12 +0200 Subject: [PATCH 4/8] test(media): run the Windows button handler test only on Windows The SMTC COM handler's JNA callbacks use the Windows stdcall convention, which JNA cannot create on Linux or macOS, so its class initializer failed there. Production opens the handler only on Windows. --- .../nextcloudnative/app/DesktopSystemMediaControlsTest.kt | 3 +++ 1 file changed, 3 insertions(+) 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)) From d5e43f28521d9b0a77a97072e3d44752674be2f2 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Mon, 19 Oct 2026 14:43:18 +0200 Subject: [PATCH 5/8] fix(desktop): check Debian multiarch directories for libVLC vlcj names only the x86_64 and i386 triplet directories, so on arm64 and other architectures the distribution libVLC in /usr/lib/ was reachable only through the recursive search removed earlier. Check the JVM's own triplet directory before vlcj's first system directory, and the other known triplets after all of vlcj's candidates, because vlcj stops at the first copy it finds even when that copy cannot load. Each directory is still checked directly. --- PLATFORMS.md | 17 ++-- ...d-discovery.md => 520-ui-test-timeouts.md} | 0 .../app/DesktopLibVlcRuntime.kt | 79 +++++++++++++++---- .../app/DesktopLibVlcDiscoveryTest.kt | 78 +++++++++++++++++- 4 files changed, 154 insertions(+), 20 deletions(-) rename changes/unreleased/{desktop-libvlc-bounded-discovery.md => 520-ui-test-timeouts.md} (100%) diff --git a/PLATFORMS.md b/PLATFORMS.md index 458e3bba8..5a1ce86dc 100644 --- a/PLATFORMS.md +++ b/PLATFORMS.md @@ -414,11 +414,18 @@ packaging may have changed. - libVLC is found once per process (`DesktopLibVlcRuntime.kt`). A copy bundled 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. `DesktopLibVlcDiscoveryTest` covers this. Windows and macOS packages + 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 source link in the packaged `libvlc` folder. Linux packages do not bundle diff --git a/changes/unreleased/desktop-libvlc-bounded-discovery.md b/changes/unreleased/520-ui-test-timeouts.md similarity index 100% rename from changes/unreleased/desktop-libvlc-bounded-discovery.md rename to changes/unreleased/520-ui-test-timeouts.md 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 f512859d0..f5d9d00c8 100644 --- a/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopLibVlcRuntime.kt +++ b/ui/src/desktopMain/kotlin/dev/obiente/nextcloudnative/app/DesktopLibVlcRuntime.kt @@ -34,7 +34,7 @@ internal class DesktopLibVlcRuntime(private val enabled: Boolean) { if (!enabled) return@lazy null try { val discovery = NativeDiscovery(*desktopLibVlcDiscoveryStrategies()) - // A library already on the default search path reports no directory. + // vlcj 4 has no default-search-path fallback: success always reports a directory. if (discovery.discover()) discovery.discoveredPath() ?: "" else null } catch (_: LinkageError) { null @@ -122,20 +122,71 @@ class DesktopBundledLibVlcDirectoryProvider : DiscoveryDirectoryProvider { * 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(): Array = arrayOf( - DesktopLibVlcDirectDiscoveryStrategy( - LinuxNativeDiscoveryStrategy(), - listOf(Regex("libvlc\\.so(?:\\.\\d)*"), Regex("libvlccore\\.so(?:\\.\\d)*")), - ), - DesktopLibVlcDirectDiscoveryStrategy( - OsxNativeDiscoveryStrategy(), - listOf(Regex("libvlc\\.dylib"), Regex("libvlccore\\.dylib")), - ), - DesktopLibVlcDirectDiscoveryStrategy( - WindowsNativeDiscoveryStrategy(), - listOf(Regex("libvlc\\.dll"), Regex("libvlccore\\.dll")), - ), +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( diff --git a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopLibVlcDiscoveryTest.kt b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopLibVlcDiscoveryTest.kt index c123f839e..9b17a3adb 100644 --- a/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopLibVlcDiscoveryTest.kt +++ b/ui/src/desktopTest/kotlin/dev/obiente/nextcloudnative/app/DesktopLibVlcDiscoveryTest.kt @@ -17,7 +17,7 @@ import uk.co.caprica.vlcj.factory.discovery.strategy.LinuxNativeDiscoveryStrateg */ class DesktopLibVlcDiscoveryTest { private val root: Path = Files.createTempDirectory("nc-libvlc-discovery") - private val linuxNames = listOf(Regex("libvlc\\.so(?:\\.\\d)*"), Regex("libvlccore\\.so(?:\\.\\d)*")) + private val linuxNames = LINUX_LIBVLC_NAMES @AfterTest fun deleteFixture() { @@ -86,6 +86,82 @@ class DesktopLibVlcDiscoveryTest { ) } + @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 { From 6099a09a480eedfa0920fc3173ffd3add5c97acd Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Mon, 19 Oct 2026 14:43:18 +0200 Subject: [PATCH 6/8] docs(changelog): record the libVLC fallback fix separately from test timeouts --- .../520-desktop-video-fallback-without-libvlc.md | 7 +++++++ changes/unreleased/520-ui-test-timeouts.md | 4 ++-- 2 files changed, 9 insertions(+), 2 deletions(-) create mode 100644 changes/unreleased/520-desktop-video-fallback-without-libvlc.md 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 index 7ee708613..9c3d740b0 100644 --- a/changes/unreleased/520-ui-test-timeouts.md +++ b/changes/unreleased/520-ui-test-timeouts.md @@ -1,7 +1,7 @@ category: internal issue: none -pull: none +pull: 520 platforms: desktop user-facing: no -Desktop libVLC discovery checks each candidate directory without walking its subdirectories, and desktop tests name any test running over 3 minutes and stop after 30, so a machine without libVLC no longer stalls the Linux test run. +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. From e672909552eb10403ebe42a0827ad1232cabaee0 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Mon, 19 Oct 2026 15:43:38 +0200 Subject: [PATCH 7/8] build(ui): move libVLC packaging tasks into the module build script Android lint 9.3.1 visits a Kotlin script applied with apply(from = ...) and crashes analysing it (findFirCompiledSymbol only works on compiled declarations), which failed :ui:lintAnalyzeRelease on main as well. The crash occurs whatever the applied script contains and wherever it lives, and disappears without the apply call. The download and staging tasks now live in ui/build.gradle.kts unchanged; the CI path filters and docs point there. --- .github/workflows/ci.yml | 2 - PLATFORMS.md | 2 +- release/third-party/libvlc/README.md | 5 +- ui/build.gradle.kts | 204 ++++++++++++++++++++++++++- ui/desktop-libvlc.gradle.kts | 199 -------------------------- 5 files changed, 206 insertions(+), 206 deletions(-) delete mode 100644 ui/desktop-libvlc.gradle.kts 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/PLATFORMS.md b/PLATFORMS.md index 5a1ce86dc..4812f8df2 100644 --- a/PLATFORMS.md +++ b/PLATFORMS.md @@ -427,7 +427,7 @@ packaging may have changed. 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/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 1a61de908..24a6714a5 100644 --- a/ui/build.gradle.kts +++ b/ui/build.gradle.kts @@ -4,6 +4,11 @@ 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 @@ -166,7 +171,202 @@ 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 @@ -309,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"), - ) - } -} From 41d6592d74fd213b8d473b9b52e79e3d16640c48 Mon Sep 17 00:00:00 2001 From: "obiente-automations[bot]" <311907242+obiente-automations[bot]@users.noreply.github.com> Date: Mon, 19 Oct 2026 13:46:51 +0000 Subject: [PATCH 8/8] chore(website): refresh marketing captures --- website/public/screenshots/capture-manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/public/screenshots/capture-manifest.json b/website/public/screenshots/capture-manifest.json index 67ea8f586..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": "ac59459c6765829826db40f60ddab9ca4280a96a54ddb95462d9434704007e0c", + "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",