diff --git a/docs/linux-port/wayland-previews.md b/docs/linux-port/wayland-previews.md new file mode 100644 index 000000000000..b52096957911 --- /dev/null +++ b/docs/linux-port/wayland-previews.md @@ -0,0 +1,52 @@ +# Media and HTML previews without native window embedding + +Status: media playback and rendered HTML previews are implemented for the preview pane. The release notes page still opens the browser. This note records why, how, and what a fully interactive HTML view would cost. + +## What the X11 host actually did + +`NativePreviewSupport.IsAvailable` (true on the X11 host) suggested that X11 embeds native previews. It does not in LinuxFiles: + +- `MediaPlayerElement` needs the `Uno.WinUI.MediaPlayer.Skia.X11` add-in (libVLC) and `WebView2` needs `Uno.WinUI.WebView.Skia.X11` (WebKitGTK 4.1, embedded as an X11 child window). LinuxFiles references neither package. `LibVLCSharp.dll` only ships because `Uno.WinUI.Runtime.Skia.X11` depends on it. +- On the desktop target the info pane never creates `MediaPreview` or `HtmlPreview`. Media showed cover art and tags only, and HTML showed its source as highlighted code on X11 or as plain text through `NativePreviewFallback` on Wayland. +- `ReleaseNotesPage` does instantiate `WebView2` on X11, but without the add-in there is no native web view behind it. + +So the Wayland work did not need to match an existing X11 implementation. The new previews draw through Skia, so they work the same way on both hosts. + +## Media: libmpv software rendering (implemented) + +- `Files.Platform.Linux/Media/MpvMediaPlayer.cs` loads `libmpv.so.2` at runtime (`LibraryImport`, no `DirectPInvoke`), so mpv is an optional dependency. Without it, the previous metadata preview is shown. +- Frames come from the libmpv render API with `MPV_RENDER_API_TYPE_SW` (`bgr0`, forced opaque) on a dedicated render thread. They are sized to the pane in device pixels (at most 1920x1080) and copied into a `WriteableBitmap`. UI updates are coalesced, so a slow UI thread drops frames instead of queuing them. mpv handles audio output itself. +- Nothing is decoded when a file is selected. mpv is created on the first Play (button or Ctrl+Space), so browsing stays cheap and untrusted media is only parsed when the user asks for it. Playback stops as soon as the selection changes, before the 100 ms preview debounce. +- The file is opened with `PreviewFile.OpenRead` (regular files only, inode pinned), and mpv loads it through `/proc/self/fd/`, so FIFOs, devices, and path swaps after the check are rejected. +- Hardening: user configuration, scripts, ytdl, and auto-loaded external files are disabled. Only the lavf demuxer runs, limited to `protocol_whitelist=[file]` and a container whitelist (no HLS, DASH, or concat). A file that expands into a playlist or changes path after loading is rejected, so a text playlist renamed to `.mp4` never fetches URLs. `audio-fallback-to-null` lets audio-only files play when no audio device is available. +- Cost: software scaling and conversion use CPU in proportion to the pane size, which is small. `hwdec` stays off for robustness. A future OpenGL render path (see the subsurface section) could hand frames to Skia as GPU textures. + +## HTML: offscreen WebKitGTK snapshot in a helper process (implemented) + +- `Files --linuxfiles-html-snapshot ` is handled first in `Main`. It initialises GTK 3 and WebKitGTK 4.1 in a short-lived child process, loads the file in a `GtkOffscreenWindow`, takes a snapshot, and writes a PNG to stdout. The app shows the PNG in a scroll viewer at the pane width. +- The work runs out of process because `gtk_init` changes the process locale and needs its own main loop, and a WebKit crash must not take down the file manager. WebKit also starts its own sandboxed web process. +- Hardening: the WebKit web process runs in WebKit's bubblewrap sandbox (`webkit_web_context_set_sandbox_enabled`, set before the first view is created, with no extra sandbox paths). If `bwrap` or `xdg-dbus-proxy` is missing, or WebKit would skip bubblewrap (Snap or Flatpak markers, `WEBKIT_DISABLE_SANDBOX_THIS_IS_DANGEROUS`), the helper refuses to render and the text fallback is shown, the same fail-closed policy as unsandboxed external thumbnailers. JavaScript, plugins, WebGL, and media are off, and navigation, new windows, and downloads are refused. The data manager is ephemeral. +- No network: the bounded document (up to 8 MiB) is loaded with `webkit_web_view_load_html`, with a Content-Security-Policy `` prepended and the file's own URI as the base. The policy allows only `file:` and `data:` images, styles, and fonts. `` ignores CSP and was verified to open a TCP connection, so the `LinkPreconnect`, `LinkPreconnectEarlyHints`, `LinkPrefetch`, and `SpeculationRulesPrefetch` WebKit features are turned off. This needs WebKitGTK 2.42 or later. On older versions the helper refuses to render and the text fallback is shown. +- Limits: 10 s in the helper, 15 s in the parent, a 64 MiB PNG cap, and a height cap of 4096 CSS pixels. The offscreen window is that tall and the snapshot covers only the visible region, so a page with a huge CSS height cannot make WebKit allocate a huge image. Trailing rows in the page background colour (the top-left pixel) are trimmed, so solid footers in another colour are kept. HiDPI uses the WebKit page zoom (1x or 2x) rather than `GDK_SCALE`, because GTK 3 only honours `GDK_SCALE` on X11. The helper is killed when the selection changes. +- What users see: the preview is a static picture. Links do not respond, `:hover` and scripted content are not shown, very long pages are cut off at the height limit, and documents that are neither UTF-8 nor BOM-marked Unicode fall back to source text. "Open with default app" stays available. +- Optional dependency: `webkit2gtk-4.1` (and GTK 3). When it is missing, or rendering fails, the plain text fallback is shown as before. + +## Packaging + +- AUR: `mpv` and `webkit2gtk-4.1` are listed as optional dependencies. +- Nix: neither library is in the flake's `runtimeLibs` yet, because WebKitGTK would add a large closure. Both previews fall back on NixOS until the flake adds them (perhaps behind an option). +- Flatpak: not covered yet. + +## Release notes (not changed) + +`ReleaseNotesPage` points at the upstream Files blog (`files.community/blog/posts/v…?minimal`), which describes the Windows app and needs network access and interactive links. That makes it a poor fit for a snapshot. On Wayland it keeps opening the page in the default browser. A better fix is to render the LinuxFiles GitHub release body (Markdown) with the existing Markdig preview renderer, which needs a fetch and caching design of its own. + +## Interactive HTML on Wayland: options and cost + +A live, scrollable, clickable web view on the native Wayland host would need one of these: + +1. **Native element hosting via `wl_subsurface` in the Uno Wayland host.** The host would create a subsurface per native element, keep its position and clip in sync with layout (including scrolling, transforms, and DPI changes), and hand the toolkit a surface to draw into. libmpv could render into it through its OpenGL render API on an EGL window surface. WebKitGTK cannot: GTK 3 and GTK 4 widgets only render into their own toplevel `GdkSurface`, not into a foreign `wl_surface`. Airspace problems apply as on X11, because flyouts, menus, and tooltips over the element would be hidden. Estimated cost: a new Uno host subsystem (about 1.5k lines plus tests), upstream review, and it still would not help HTML. +2. **WPE WebKit with a custom view backend (`libwpe` + `wpebackend-fdo`, or the WPE platform API).** WPE exports frames as EGLImages, dma-bufs, or SHM buffers that can be drawn into Skia, and it accepts injected pointer, keyboard, and scroll events. This is the right route to a real web view on Wayland. Cost: new native dependencies that most desktop distributions do not install by default, GL and EGL interop with Uno's Skia context (or an SHM copy), input translation, and lifecycle and threading work. Estimated at 2 to 3k lines across an Uno add-in, `Uno.UI.WebView.Skia.Wayland` (`INativeWebView`), and its tests. This is the recommended follow-up if interactive web content becomes a requirement. It would also give LinuxFiles a working `WebView2` on Wayland for the release notes page. +3. **WebKitGTK offscreen with input forwarding.** Extend the snapshot helper into a long-lived process that streams damaged regions over shared memory and receives input events. This avoids new dependencies but means reimplementing a compositor protocol between the two processes, with high latency and complexity. Not recommended over WPE. + +For read-only previews, the static snapshot gives most of the value at a small fraction of these costs. diff --git a/packaging/linux/aur/linuxfiles-bin/PKGBUILD b/packaging/linux/aur/linuxfiles-bin/PKGBUILD index d9bb514da159..6e681c372f97 100644 --- a/packaging/linux/aur/linuxfiles-bin/PKGBUILD +++ b/packaging/linux/aur/linuxfiles-bin/PKGBUILD @@ -18,7 +18,9 @@ optdepends=('polkit: authenticated root actions' 'gvfs: network and MTP locations' 'udisks2: mount and eject drives' 'libsecret: saved network credentials (Secret Service provider)' - 'gnome-disk-utility: format and manage drives') + 'gnome-disk-utility: format and manage drives' + 'mpv: audio and video playback in the preview pane' + 'webkit2gtk-4.1: rendered HTML previews') provides=("$_pkgname") conflicts=("$_pkgname") options=('!strip' '!debug') # stripping breaks the self-contained .NET binaries diff --git a/packaging/linux/aur/linuxfiles/PKGBUILD b/packaging/linux/aur/linuxfiles/PKGBUILD index 9f2a2d4b9174..095110104f05 100644 --- a/packaging/linux/aur/linuxfiles/PKGBUILD +++ b/packaging/linux/aur/linuxfiles/PKGBUILD @@ -11,7 +11,7 @@ license=('MIT') # Self-contained publish bundles the runtime; native libs below are loaded by Skia/Uno at runtime. depends=('fontconfig' 'freetype2' 'libx11' 'libxcursor' 'libxrandr' 'libxi' 'libxext' 'wayland' 'libxkbcommon' 'libglvnd' 'mesa' 'glib2' 'hicolor-icon-theme') makedepends=('dotnet-sdk>=10.0' 'git' 'clang' 'zlib' 'python') -optdepends=('polkit: authenticated root actions' 'gvfs: network and MTP locations' 'gnome-disk-utility: format and manage drives') +optdepends=('polkit: authenticated root actions' 'gvfs: network and MTP locations' 'gnome-disk-utility: format and manage drives' 'mpv: audio and video playback in the preview pane' 'webkit2gtk-4.1: rendered HTML previews') options=('!strip') # .NET single-file/self-contained binaries break when stripped # Generated from build/uno-wayland-packages.sha256; templates require complete pins. _uno_version=6.7.135-wayland.6 diff --git a/src/Files.App/Platforms/Desktop/Program.cs b/src/Files.App/Platforms/Desktop/Program.cs index 68ab8842bb90..d2fdcf03eb65 100644 --- a/src/Files.App/Platforms/Desktop/Program.cs +++ b/src/Files.App/Platforms/Desktop/Program.cs @@ -39,6 +39,7 @@ internal sealed class Program [STAThread] public static int Main(string[] args) { + if (Files.Platform.Linux.Previews.HtmlSnapshotHelper.TryRunHelper(args, out var helperExitCode)) return helperExitCode; try { using var trace = Files.Platform.Abstractions.Diagnostics.PerformanceTrace.Begin("startup.root-environment"); diff --git a/src/Files.App/Strings/en-US/Resources.resw b/src/Files.App/Strings/en-US/Resources.resw index b5396cfc74c8..cced72f02c60 100644 --- a/src/Files.App/Strings/en-US/Resources.resw +++ b/src/Files.App/Strings/en-US/Resources.resw @@ -5135,4 +5135,17 @@ Requires a restart. Uses the focused compositor output scale (niri, sway or Hyprland), KDE display scale, or GNOME text scale when no display or toolkit scale is configured. Scale changes require a restart. May double-scale on some setups. + + Seek + + + Volume + + + This file could not be played. + + + {0} / {1} + Playback elapsed time ({0}) and total duration ({1}). + diff --git a/src/Files.App/UserControls/FilePreviews/LinuxHtmlPreview.xaml b/src/Files.App/UserControls/FilePreviews/LinuxHtmlPreview.xaml new file mode 100644 index 000000000000..9f3abb103433 --- /dev/null +++ b/src/Files.App/UserControls/FilePreviews/LinuxHtmlPreview.xaml @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Files.App/UserControls/FilePreviews/LinuxMediaPreview.xaml.cs b/src/Files.App/UserControls/FilePreviews/LinuxMediaPreview.xaml.cs new file mode 100644 index 000000000000..cdc0c201edd2 --- /dev/null +++ b/src/Files.App/UserControls/FilePreviews/LinuxMediaPreview.xaml.cs @@ -0,0 +1,237 @@ +// Copyright (c) Files Community +// Licensed under the MIT License. + +#if DESKTOP +using Files.App.ViewModels.Previews; +using Files.Platform.Linux.Media; +using Microsoft.UI.Dispatching; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Automation; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Controls.Primitives; +using Microsoft.UI.Xaml.Media.Imaging; +using System.Globalization; +using System.Runtime.InteropServices.WindowsRuntime; + +namespace Files.App.UserControls.FilePreviews +{ + public sealed partial class LinuxMediaPreview : UserControl + { + private readonly IUserSettingsService settings = Ioc.Default.GetRequiredService(); + private readonly DispatcherQueue windowDispatcher; + private readonly object frameGate = new(); + private readonly SemaphoreSlim commandGate = new(1, 1); + private MpvMediaPlayer? player; + private MpvFrame? latestFrame; + private WriteableBitmap? bitmap; + private bool uiPending; + private bool updatingControls = true; + private bool commandBusy; + private bool failed; + private int unloaded; + private bool stopped; + private XamlRoot? previewRoot; + private readonly CancellationTokenRegistration selectionRegistration; + + public MediaMetadataPreviewViewModel ViewModel { get; } + + public LinuxMediaPreview(MediaMetadataPreviewViewModel viewModel, CancellationToken selectionToken) + { + ViewModel = viewModel; + windowDispatcher = MainWindow.Instance.DispatcherQueue; + // The pane keeps this control while it loads the next selection; playback must stop as soon as the selection changes. + selectionRegistration = selectionToken.Register(() => windowDispatcher.TryEnqueue(StopPlayback)); + InitializeComponent(); + VolumeSlider.Value = Math.Clamp(settings.InfoPaneSettingsService.MediaVolume, 0, 1); + PlaybackTime.Text = FormatPlaybackTime(TimeSpan.Zero, TimeSpan.Zero); + updatingControls = false; + Loaded += (_, _) => + { + previewRoot = XamlRoot; + if (previewRoot is not null) previewRoot.Changed += Root_Changed; + UpdateTargetSize(); + }; + Unloaded += Preview_Unloaded; + } + + public async void TogglePlayback() + { + if (Volatile.Read(ref unloaded) != 0 || commandBusy || failed || stopped) return; + commandBusy = true; + try + { + if (player is null) + { + // Construction is cheap; native initialization and parsing start only on explicit Play. + var created = new MpvMediaPlayer { Volume = VolumeSlider.Value }; + player = created; + created.FrameRendered += Player_FrameRendered; + created.StateChanged += Player_StateChanged; + UpdateTargetSize(); + var path = ViewModel.Item.GetRequiredPath(); + await RunCommandAsync(() => { created.Open(path); created.Play(); }); + } + else + { + var current = player; + await RunCommandAsync(current.TogglePause); + } + } + catch (Exception) { ShowFailure(); } + finally { commandBusy = false; } + } + + private async Task RunCommandAsync(Action command) + { + await commandGate.WaitAsync(); + try + { + if (Volatile.Read(ref unloaded) == 0) await Task.Run(command); + } + finally { commandGate.Release(); } + } + + private void Play_Click(object sender, RoutedEventArgs e) => TogglePlayback(); + + private void Player_StateChanged(object? sender, EventArgs e) => QueueUiUpdate(); + + private void Player_FrameRendered(MpvFrame frame) + { + lock (frameGate) + { + if (Volatile.Read(ref unloaded) != 0) return; + // Each frame owns its pixels. Replacing this reference drops obsolete frames safely. + latestFrame = frame; + } + QueueUiUpdate(); + } + + private void QueueUiUpdate() + { + lock (frameGate) + { + if (Volatile.Read(ref unloaded) != 0 || uiPending) return; + uiPending = true; + if (!windowDispatcher.TryEnqueue(UpdateUi)) uiPending = false; + } + } + + private void UpdateUi() + { + MpvFrame? frame; + lock (frameGate) + { + uiPending = false; + frame = latestFrame; + latestFrame = null; + } + if (Volatile.Read(ref unloaded) != 0 || failed || player is not { } current) return; + if (current.Error is not null) { ShowFailure(); return; } + try + { + if (frame is not null) + { + if (bitmap is null || bitmap.PixelWidth != frame.Width || bitmap.PixelHeight != frame.Height) + { + bitmap = new WriteableBitmap(frame.Width, frame.Height); + Video.Source = bitmap; + } + using (var stream = bitmap.PixelBuffer.AsStream()) stream.Write(frame.Pixels.Span); + bitmap.Invalidate(); + Video.Visibility = Visibility.Visible; + Cover.Visibility = Visibility.Collapsed; + } + updatingControls = true; + var duration = current.Duration.TotalSeconds; + SeekSlider.Maximum = Math.Max(1, duration); + SeekSlider.IsEnabled = duration > 0; + SeekSlider.Value = Math.Clamp(current.Position.TotalSeconds, 0, SeekSlider.Maximum); + PlaybackTime.Text = FormatPlaybackTime(current.Position, current.Duration); + var label = (current.IsPaused ? Strings.Play : Strings.Key_Pause).GetLocalizedResource(); + PlayIcon.Glyph = current.IsPaused ? "\uE768" : "\uE769"; + AutomationProperties.SetName(PlayButton, label); + ToolTipService.SetToolTip(PlayButton, label); + } + catch (Exception) { ShowFailure(); } + finally { updatingControls = false; } + } + + private async void Seek_ValueChanged(object sender, RangeBaseValueChangedEventArgs e) + { + if (updatingControls || Volatile.Read(ref unloaded) != 0 || player is not { } current) return; + try { await RunCommandAsync(() => current.Seek(TimeSpan.FromSeconds(e.NewValue))); } + catch (Exception) { ShowFailure(); } + } + + private async void Volume_ValueChanged(object sender, RangeBaseValueChangedEventArgs e) + { + if (updatingControls || Volatile.Read(ref unloaded) != 0) return; + settings.InfoPaneSettingsService.MediaVolume = e.NewValue; + if (failed) return; + if (player is not { } current) return; + try { await RunCommandAsync(() => current.Volume = e.NewValue); } + catch (Exception) { ShowFailure(); } + } + + private static string FormatPlaybackTime(TimeSpan position, TimeSpan duration) => string.Format(CultureInfo.CurrentCulture, + Strings.LinuxMediaTimeFormat.GetLocalizedResource(), FormatTime(position), FormatTime(duration)); + + private static string FormatTime(TimeSpan time) => time.ToString(time.TotalHours >= 1 ? @"h\:mm\:ss" : @"m\:ss", CultureInfo.CurrentCulture); + + private void VideoHost_SizeChanged(object sender, SizeChangedEventArgs e) => UpdateTargetSize(); + + private void Root_Changed(XamlRoot sender, XamlRootChangedEventArgs args) => UpdateTargetSize(); + + private void UpdateTargetSize() + { + var scale = XamlRoot?.RasterizationScale ?? 1; + player?.SetTargetSize((int)Math.Clamp(VideoHost.ActualWidth * scale, 1, 1920), + (int)Math.Clamp(VideoHost.ActualHeight * scale, 1, 1080)); + } + + private void ShowFailure() + { + if (Volatile.Read(ref unloaded) != 0 || stopped) return; + failed = true; + Video.Visibility = Visibility.Collapsed; + Video.Source = null; + bitmap = null; + Cover.Visibility = Visibility.Visible; + PlaybackError.Visibility = Visibility.Visible; + PlayIcon.Glyph = "\uE768"; + PlayButton.IsEnabled = SeekSlider.IsEnabled = false; + DisposePlayer(); + } + + private void StopPlayback() + { + if (Volatile.Read(ref unloaded) != 0 || stopped) return; + stopped = true; + PlayButton.IsEnabled = SeekSlider.IsEnabled = false; + PlayIcon.Glyph = "\uE768"; + DisposePlayer(); + } + + private void DisposePlayer() + { + if (player is not { } current) return; + player = null; + current.FrameRendered -= Player_FrameRendered; + current.StateChanged -= Player_StateChanged; + current.Dispose(); + } + + private void Preview_Unloaded(object sender, RoutedEventArgs e) + { + Interlocked.Exchange(ref unloaded, 1); + selectionRegistration.Dispose(); + if (previewRoot is not null) previewRoot.Changed -= Root_Changed; + DisposePlayer(); + lock (frameGate) latestFrame = null; + Video.Source = null; + bitmap = null; + ViewModel.PreviewControlBase_Unloaded(sender, e); + } + } +} +#endif diff --git a/src/Files.App/ViewModels/UserControls/InfoPaneViewModel.cs b/src/Files.App/ViewModels/UserControls/InfoPaneViewModel.cs index 8ddeceeb5aaf..d5ca844ea365 100644 --- a/src/Files.App/ViewModels/UserControls/InfoPaneViewModel.cs +++ b/src/Files.App/ViewModels/UserControls/InfoPaneViewModel.cs @@ -179,6 +179,10 @@ private async void ContentPageContext_PropertyChanged(object? sender, PropertyCh if (contentPageContext.SelectedItems.Count == 1) tempSelectedItem = contentPageContext.SelectedItems.First(); + // Stop the current preview (e.g. media playback) right away; only loading the next one is debounced + if (!Equals(tempSelectedItem, SelectedItem)) + CancelPreviewLoad(); + // Don't update preview pane when the selected item changes too frequently const int delayBeforeUpdatingPreviewPane = 100; await Task.Delay(delayBeforeUpdatingPreviewPane); @@ -314,15 +318,18 @@ private async Task LoadPreviewControlAsync(CancellationToken token, bool downloa (FileExtensionHelpers.IsAudioFile(ext) || FileExtensionHelpers.IsVideoFile(ext))) { #if DESKTOP - if (!NativePreviewSupport.IsAvailable) + if (!Files.Platform.Linux.Media.MpvMediaPlayer.IsAvailable && !NativePreviewSupport.IsAvailable) return await NativePreviewFallback.CreateMediaAsync(item, token); - // LINUX-TODO(media): metadata only; playback needs libvlc (see MediaMetadataPreviewViewModel) + // Optional libmpv playback starts only when the user presses Play. var model = new MediaMetadataPreviewViewModel(item); using var registration = token.Register(model.LoadCancelledTokenSource.Cancel); await model.LoadAsync(); + token.ThrowIfCancellationRequested(); - return new BasicPreview(model); + return Files.Platform.Linux.Media.MpvMediaPlayer.IsAvailable + ? new LinuxMediaPreview(model, token) + : new BasicPreview(model); #else var model = new MediaPreviewViewModel(item); await model.LoadAsync(); @@ -331,10 +338,15 @@ private async Task LoadPreviewControlAsync(CancellationToken token, bool downloa #endif } +#if DESKTOP + if (ext is ".html" or ".htm" && Files.Platform.Linux.Previews.HtmlSnapshotRenderer.IsAvailable) + return new LinuxHtmlPreview(new HtmlPreviewViewModel(item), token); +#endif + if (ext is ".html" or ".htm" && !NativePreviewSupport.IsAvailable) return await NativePreviewFallback.CreateHtmlAsync(item, token); - // Markdown and highlighted code render as inert text with Markdig / ColorCode.Core; HTML is shown as source, never rendered. + // Markdown and highlighted code render as inert text with Markdig / ColorCode.Core; HTML falls back to source. // LINUX-TODO(preview): shell scripts and other languages without a ColorCode grammar are shown as plain text if (FileExtensionHelpers.IsMarkdownFile(ext) && await TextPreviewViewModel.TryLoadWithKindAsync(item, TextPreviewKind.Markdown, null, token) is { } markdownPreview) diff --git a/src/Files.App/ViewModels/UserControls/Previews/MediaMetadataPreviewViewModel.cs b/src/Files.App/ViewModels/UserControls/Previews/MediaMetadataPreviewViewModel.cs index 3a165cd26127..097403267cc0 100644 --- a/src/Files.App/ViewModels/UserControls/Previews/MediaMetadataPreviewViewModel.cs +++ b/src/Files.App/ViewModels/UserControls/Previews/MediaMetadataPreviewViewModel.cs @@ -10,9 +10,8 @@ namespace Files.App.ViewModels.Previews { /// - /// Audio/video preview on Linux: embedded cover art (or the thumbnail) plus tag metadata. Nothing is played. + /// Audio/video cover art (or the thumbnail) and tag metadata. Optional libmpv playback is created by the preview control on explicit Play. /// - // LINUX-TODO(media): playback needs MediaPlayerElement with Uno.UI.MediaPlayer.Skia.X11 and a system libvlc; until that is verified the preview is metadata only. public sealed partial class MediaMetadataPreviewViewModel : BasePreviewModel { private const int MaxCoverBytes = 8 * 1024 * 1024; diff --git a/src/Files.App/Views/Shells/BaseShellPage.cs b/src/Files.App/Views/Shells/BaseShellPage.cs index 6e7c9a224636..b371a1847edf 100644 --- a/src/Files.App/Views/Shells/BaseShellPage.cs +++ b/src/Files.App/Views/Shells/BaseShellPage.cs @@ -407,6 +407,13 @@ protected void ShellPage_PreviewKeyDown(object sender, KeyRoutedEventArgs args) mediaPreviewContent.ViewModel.TogglePlayback(); args.Handled = true; } +#if DESKTOP + else if (Ioc.Default.GetRequiredService().PreviewPaneContent is UserControls.FilePreviews.LinuxMediaPreview linuxMediaPreview) + { + linuxMediaPreview.TogglePlayback(); + args.Handled = true; + } +#endif break; } } diff --git a/src/Files.Platform.Linux/Media/MpvMediaPlayer.cs b/src/Files.Platform.Linux/Media/MpvMediaPlayer.cs new file mode 100644 index 000000000000..ab24e96d8cfe --- /dev/null +++ b/src/Files.Platform.Linux/Media/MpvMediaPlayer.cs @@ -0,0 +1,485 @@ +// Copyright (c) Files Community +// Licensed under the MIT License. + +using Files.Platform.Linux.Native; +using Files.Platform.Linux.Previews; +using System; +using System.Globalization; +using System.IO; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using System.Threading; +using System.Threading.Tasks; + +namespace Files.Platform.Linux.Media +{ + /// A frame owns its immutable BGRA pixels; it remains valid after the callback and player disposal. + public sealed class MpvFrame + { + public int Width { get; } + public int Height { get; } + public int Stride { get; } + public ReadOnlyMemory Pixels { get; } + + internal MpvFrame(int width, int height, byte[] pixels) + { + Width = width; + Height = height; + Stride = width * 4; + Pixels = pixels; + } + } + + /// + /// Optional in-process software playback. Open/commands may block and should be called off the UI thread. + /// Events run on background threads. Dispose schedules teardown; Disposal completes after native resources are freed. + /// + public sealed unsafe class MpvMediaPlayer : IDisposable + { + private static readonly Lazy available = new(ProbeAvailability); + private static readonly object localeGate = new(); + + // Fail closed if any option is unavailable: never silently omit a security restriction. + internal static ReadOnlySpan<(string Name, string Value)> Options => new (string, string)[] + { + ("vo", "libmpv"), ("config", "no"), ("load-scripts", "no"), ("ytdl", "no"), + ("osc", "no"), ("input-default-bindings", "no"), ("input-vo-keyboard", "no"), + ("terminal", "no"), ("hwdec", "no"), ("keep-open", "yes"), ("pause", "yes"), + ("idle", "yes"), ("audio-display", "embedded-first"), ("access-references", "no"), + ("load-unsafe-playlists", "no"), ("autoload-files", "no"), ("sub-auto", "no"), + ("audio-file-auto", "no"), ("cover-art-auto", "no"), ("resume-playback", "no"), + ("save-position-on-quit", "no"), ("autocreate-playlist", "no"), ("prefetch-playlist", "no"), + ("ordered-chapters", "no"), + // Without an audio device, audio-only files would otherwise fail to load. + ("audio-fallback-to-null", "yes"), + // Bypass mpv's playlist/EDL/directory demuxers. Restrict nested libavformat IO as well. + ("demuxer", "lavf"), + // Only the media containers used by the pane; exclude HLS, DASH, concat and other reference formats. + ("demuxer-lavf-o", "protocol_whitelist=[file],format_whitelist=[mov,mp4,m4a,3gp,3g2,mj2,matroska,webm,avi,asf,mp3,aac,flac,ogg,wav]"), + ("stream-lavf-o", "protocol_whitelist=[file]") + }; + + private readonly object nativeGate = new(); + private readonly object stateGate = new(); + private readonly AutoResetEvent renderSignal = new(false); + private readonly TaskCompletionSource disposal = new(TaskCreationOptions.RunContinuationsAsynchronously); + private nint handle; + private nint renderContext; + private GCHandle callbackHandle; + private Thread? eventThread; + private Thread? renderThread; + private int disposed; + private int validated; + private int redraw; + private int targetWidth = 640; + private int targetHeight = 360; + private string? requestedPath; + private FileStream? pinnedFile; + private bool playRequested; + private double volume = 1; + private double position; + private double duration; + private bool paused = true; + private bool ended; + private int videoWidth; + private int videoHeight; + private string? error; + + public static bool IsAvailable => available.Value; + public Task Disposal => disposal.Task; + public event EventHandler? StateChanged; + /// Runs on the render thread; handlers must not call synchronous playback commands. + public event Action? FrameRendered; + public TimeSpan Position { get { lock (stateGate) return ToTime(position); } } + public TimeSpan Duration { get { lock (stateGate) return ToTime(duration); } } + public bool IsPaused { get { lock (stateGate) return paused; } } + public bool IsEnded { get { lock (stateGate) return ended; } } + public int VideoWidth { get { lock (stateGate) return videoWidth; } } + public int VideoHeight { get { lock (stateGate) return videoHeight; } } + public bool HasVideo { get { lock (stateGate) return videoWidth > 0 && videoHeight > 0; } } + /// Diagnostic only; UI consumers should show a localized playback failure message. + public string? Error { get { lock (stateGate) return error; } } + + public double Volume + { + get { lock (nativeGate) return volume; } + set + { + lock (nativeGate) + { + ThrowIfDisposed(); + volume = double.IsFinite(value) ? Math.Clamp(value, 0, 1) : 0; + if (handle != 0) SetDouble("volume", volume * 100); + } + } + } + + public void Open(string absolutePath) + { + if (!Path.IsPathFullyQualified(absolutePath) || absolutePath.Contains('\0')) + throw new ArgumentException("An absolute local path is required.", nameof(absolutePath)); + lock (nativeGate) + { + ThrowIfDisposed(); + if (requestedPath is not null) throw new InvalidOperationException("Use a new player for each file."); + requestedPath = Path.GetFullPath(absolutePath); + try + { + // Only regular files; mpv reads the inode checked here even if the path is swapped afterwards. + pinnedFile = PreviewFile.OpenRead(requestedPath); + requestedPath = FormattableString.Invariant($"/proc/self/fd/{pinnedFile.SafeFileHandle.DangerousGetHandle()}"); + Initialize(); + ThrowIfDisposed(); + Command("loadfile", requestedPath, "replace"); + } + catch (Exception ex) + { + Fail(ex.Message); + Dispose(); + throw; + } + } + } + + public void Play() + { + lock (nativeGate) + { + ThrowIfDisposed(); + playRequested = true; + if (Volatile.Read(ref validated) == 0) return; + if (IsEnded) Command("seek", "0", "absolute"); + SetFlag("pause", false); + } + } + + public void Pause() + { + lock (nativeGate) + { + ThrowIfDisposed(); + playRequested = false; + if (handle != 0) SetFlag("pause", true); + } + } + + public void TogglePause() + { + lock (nativeGate) + { + if (Volatile.Read(ref validated) == 0 ? playRequested : !IsPaused) Pause(); + else Play(); + } + } + + public void Seek(TimeSpan time) + { + lock (nativeGate) + { + ThrowIfDisposed(); + if (Volatile.Read(ref validated) != 0) + Command("seek", Math.Max(0, time.TotalSeconds).ToString("R", CultureInfo.InvariantCulture), "absolute+keyframes"); + } + } + + public void SetTargetSize(int widthPx, int heightPx) + { + lock (stateGate) + { + if (Volatile.Read(ref disposed) != 0) return; + targetWidth = Math.Clamp(widthPx, 1, 1920); + targetHeight = Math.Clamp(heightPx, 1, 1080); + Interlocked.Exchange(ref redraw, 1); + // The signal is disposed under this lock after both worker threads have exited. + renderSignal.Set(); + } + } + + internal static (int Width, int Height) FitSize(int width, int height, int videoWidth, int videoHeight) + { + width = Math.Clamp(width, 1, 1920); + height = Math.Clamp(height, 1, 1080); + if (videoWidth <= 0 || videoHeight <= 0) return (width, height); + var scale = Math.Min((double)width / videoWidth, (double)height / videoHeight); + return (Math.Clamp((int)Math.Round(videoWidth * scale), 1, width), + Math.Clamp((int)Math.Round(videoHeight * scale), 1, height)); + } + + private static bool ProbeAvailability() + { + try + { + if (!NativeLibrary.TryLoad("libmpv.so.2", out var library)) return false; + try { return (MpvNative.mpv_client_api_version() >> 16) == 2; } + finally { NativeLibrary.Free(library); } + } + catch { return false; } + } + + private void Initialize() + { + if (!IsAvailable) throw new NotSupportedException("libmpv client API 2 is unavailable."); + lock (localeGate) + { + // LC_NUMERIC is process-wide. mpv requires C for its entire lifetime. + if (MpvNative.SetLocale(1, "C") == 0) throw new InvalidOperationException("Cannot set LC_NUMERIC."); + handle = MpvNative.mpv_create(); + } + if (handle == 0) throw new InvalidOperationException("Cannot create mpv."); + foreach (var (name, value) in Options) Check(MpvNative.mpv_set_option_string(handle, name, value)); + Check(MpvNative.mpv_initialize(handle)); + SetDouble("volume", volume * 100); + string[] properties = ["time-pos", "duration", "pause", "eof-reached", "dwidth", "dheight"]; + for (var i = 0; i < properties.Length; i++) + Check(MpvNative.mpv_observe_property(handle, (ulong)i + 1, properties[i], + i is 2 or 3 ? MpvNative.FormatFlag : MpvNative.FormatDouble)); + + // Create before loadfile; subsequent render calls and free belong exclusively to the render thread. + fixed (byte* api = "sw\0"u8) + { + MpvNative.RenderParam* parameters = stackalloc MpvNative.RenderParam[] { new(1, (nint)api), new(0, 0) }; + Check(MpvNative.mpv_render_context_create(out renderContext, handle, parameters)); + } + callbackHandle = GCHandle.Alloc(this); + MpvNative.mpv_render_context_set_update_callback(renderContext, &RenderUpdate, GCHandle.ToIntPtr(callbackHandle)); + renderThread = new Thread(RenderLoop) { IsBackground = true, Name = "Media preview render" }; + eventThread = new Thread(EventLoop) { IsBackground = true, Name = "Media preview events" }; + renderThread.Start(); + eventThread.Start(); + } + + [UnmanagedCallersOnly(CallConvs = new[] { typeof(CallConvCdecl) })] + private static void RenderUpdate(nint context) + { + // Native teardown quiesces callbacks before the handle and signal are freed. + var player = (MpvMediaPlayer)GCHandle.FromIntPtr(context).Target!; + if (Volatile.Read(ref player.disposed) == 0) player.renderSignal.Set(); + } + + private void EventLoop() + { + try + { + while (Volatile.Read(ref disposed) == 0) + { + var evt = MpvNative.mpv_wait_event(handle, -1); + if (Volatile.Read(ref disposed) != 0) break; + switch (evt->Id) + { + case 8: // MPV_EVENT_FILE_LOADED + ValidateLoadedFile(); + break; + case 7: // MPV_EVENT_END_FILE: first fields are reason and error. + if (evt->Data != 0 && (*(int*)evt->Data is 4 or 5)) + RejectFile("Media load failed or redirected to a playlist."); + break; + case 22: // MPV_EVENT_PROPERTY_CHANGE + UpdateProperty(evt->UserData, (MpvNative.EventProperty*)evt->Data); + break; + case 1: // MPV_EVENT_SHUTDOWN + return; + } + } + } + catch (Exception ex) + { + Fail(ex.Message); + Dispose(); + } + } + + private void ValidateLoadedFile() + { + lock (nativeGate) + { + if (Volatile.Read(ref disposed) != 0) return; + double count = 0; + nint path = 0; + try + { + Check(MpvNative.mpv_get_property(handle, "playlist-count", MpvNative.FormatDouble, &count)); + Check(MpvNative.mpv_get_property(handle, "path", MpvNative.FormatString, &path)); + if (count != 1 || !string.Equals(Marshal.PtrToStringUTF8(path), requestedPath, StringComparison.Ordinal)) + { + RejectFile("Media load expanded a playlist or changed the requested path."); + return; + } + Volatile.Write(ref validated, 1); + Interlocked.Exchange(ref redraw, 1); + renderSignal.Set(); + if (playRequested) SetFlag("pause", false); + } + finally { if (path != 0) MpvNative.mpv_free(path); } + } + } + + private void UpdateProperty(ulong id, MpvNative.EventProperty* property) + { + if (property == null || property->Data == 0) return; + lock (stateGate) + { + var value = property->Format == MpvNative.FormatDouble ? *(double*)property->Data : 0; + if (!double.IsFinite(value)) value = 0; + var flag = property->Format == MpvNative.FormatFlag && *(int*)property->Data != 0; + switch (id) + { + case 1: position = value; break; + case 2: duration = value; break; + case 3: paused = flag; break; + case 4: ended = flag; break; + case 5: videoWidth = (int)Math.Clamp(value, 0, int.MaxValue); break; + case 6: videoHeight = (int)Math.Clamp(value, 0, int.MaxValue); break; + } + } + if (id is 5 or 6) + { + Interlocked.Exchange(ref redraw, 1); + renderSignal.Set(); + } + NotifyState(); + } + + private void RenderLoop() + { + try + { + while (Volatile.Read(ref disposed) == 0) + { + renderSignal.WaitOne(); + if (Volatile.Read(ref disposed) != 0) break; + var flags = MpvNative.mpv_render_context_update(renderContext); + var force = Interlocked.Exchange(ref redraw, 0) != 0; + if ((flags & 1) == 0 && !force) continue; + // Service requested frames even before FILE_LOADED validation, so the core can progress. + // Publishing waits for validation and display dimensions in RenderFrame. + if ((flags & 1) == 0 && !HasVideo) continue; + int width, height; + lock (stateGate) (width, height) = FitSize(targetWidth, targetHeight, videoWidth, videoHeight); + RenderFrame(width, height); + } + } + catch (Exception ex) + { + Fail(ex.Message); + Dispose(); + } + finally + { + MpvNative.mpv_render_context_set_update_callback(renderContext, null, 0); + MpvNative.mpv_render_context_free(renderContext); + renderContext = 0; + } + } + + private void RenderFrame(int width, int height) + { + var length = checked(width * height * 4); + var buffer = NativeMemory.AlignedAlloc((nuint)((length + 63) & ~63), 64); + if (buffer == null) throw new OutOfMemoryException(); + NativeMemory.Clear(buffer, (nuint)length); + try + { + int* size = stackalloc int[] { width, height }; + nuint stride = (nuint)width * 4; + fixed (byte* format = "bgr0\0"u8) + { + MpvNative.RenderParam* parameters = stackalloc MpvNative.RenderParam[] + { + new(17, (nint)size), new(18, (nint)format), new(19, (nint)(&stride)), + new(20, (nint)buffer), new(0, 0) + }; + Check(MpvNative.mpv_render_context_render(renderContext, parameters)); + } + if (Volatile.Read(ref disposed) != 0 || Volatile.Read(ref validated) == 0 || !HasVideo || Error is not null) return; + var pixels = new byte[length]; + Marshal.Copy((nint)buffer, pixels, 0, length); + for (var i = 3; i < length; i += 4) pixels[i] = 0xFF; + FrameRendered?.Invoke(new MpvFrame(width, height, pixels)); + } + finally { NativeMemory.AlignedFree(buffer); } + } + + private void RejectFile(string message) + { + lock (nativeGate) + { + Volatile.Write(ref validated, 0); + if (Volatile.Read(ref disposed) == 0) Command("stop"); + } + Fail(message); + } + + private void Fail(string message) + { + lock (stateGate) { error = message; paused = true; } + NotifyState(); + } + + private void NotifyState() + { + if (Volatile.Read(ref disposed) == 0) StateChanged?.Invoke(this, EventArgs.Empty); + } + + private static TimeSpan ToTime(double seconds) => TimeSpan.FromSeconds(Math.Clamp(seconds, 0, TimeSpan.MaxValue.TotalSeconds - 1)); + private void ThrowIfDisposed() => ObjectDisposedException.ThrowIf(Volatile.Read(ref disposed) != 0, this); + private static void Check(int result) + { + if (result < 0) throw new InvalidOperationException(Marshal.PtrToStringUTF8(MpvNative.mpv_error_string(result))); + } + private void SetFlag(string name, bool value) + { + int flag = value ? 1 : 0; + Check(MpvNative.mpv_set_property(handle, name, MpvNative.FormatFlag, &flag)); + } + private void SetDouble(string name, double value) => Check(MpvNative.mpv_set_property(handle, name, MpvNative.FormatDouble, &value)); + + private void Command(params string[] args) + { + byte** pointers = stackalloc byte*[args.Length + 1]; + new Span(pointers, args.Length + 1).Clear(); + try + { + for (var i = 0; i < args.Length; i++) pointers[i] = (byte*)Marshal.StringToCoTaskMemUTF8(args[i]); + Check(MpvNative.mpv_command(handle, pointers)); + } + finally + { + for (var i = 0; i < args.Length; i++) Marshal.FreeCoTaskMem((nint)pointers[i]); + } + } + + public void Dispose() + { + if (Interlocked.Exchange(ref disposed, 1) != 0) return; + _ = Task.Run(Teardown); + } + + private void Teardown() + { + try + { + lock (nativeGate) + { + renderSignal.Set(); + if (handle != 0) MpvNative.mpv_wakeup(handle); + } + // No nativeGate while joining: the event thread may still be finishing a command. + if (renderThread?.IsAlive == true) renderThread.Join(); + if (eventThread?.IsAlive == true) eventThread.Join(); + lock (nativeGate) + { + // Also handles failure before the render thread started. + if (renderContext != 0) MpvNative.mpv_render_context_free(renderContext); + if (handle != 0) MpvNative.mpv_terminate_destroy(handle); + renderContext = handle = 0; + if (callbackHandle.IsAllocated) callbackHandle.Free(); + pinnedFile?.Dispose(); + pinnedFile = null; + } + lock (stateGate) renderSignal.Dispose(); + disposal.TrySetResult(); + } + catch (Exception ex) { disposal.TrySetException(ex); } + } + } +} diff --git a/src/Files.Platform.Linux/Native/MpvNative.cs b/src/Files.Platform.Linux/Native/MpvNative.cs new file mode 100644 index 000000000000..f85e2c8619df --- /dev/null +++ b/src/Files.Platform.Linux/Native/MpvNative.cs @@ -0,0 +1,79 @@ +// Copyright (c) Files Community +// Licensed under the MIT License. + +using System.Runtime.InteropServices; + +namespace Files.Platform.Linux.Native +{ + internal static unsafe partial class MpvNative + { + internal const int FormatString = 1; + internal const int FormatFlag = 3; + internal const int FormatDouble = 5; + + [LibraryImport("libc", EntryPoint = "setlocale", StringMarshalling = StringMarshalling.Utf8)] + internal static partial nint SetLocale(int category, string locale); + + [LibraryImport("libmpv.so.2")] + internal static partial nuint mpv_client_api_version(); + [LibraryImport("libmpv.so.2")] + internal static partial nint mpv_create(); + [LibraryImport("libmpv.so.2")] + internal static partial int mpv_initialize(nint handle); + [LibraryImport("libmpv.so.2", StringMarshalling = StringMarshalling.Utf8)] + internal static partial int mpv_set_option_string(nint handle, string name, string value); + [LibraryImport("libmpv.so.2")] + internal static partial int mpv_command(nint handle, byte** args); + [LibraryImport("libmpv.so.2", StringMarshalling = StringMarshalling.Utf8)] + internal static partial int mpv_set_property(nint handle, string name, int format, void* data); + [LibraryImport("libmpv.so.2", StringMarshalling = StringMarshalling.Utf8)] + internal static partial int mpv_get_property(nint handle, string name, int format, void* data); + [LibraryImport("libmpv.so.2", StringMarshalling = StringMarshalling.Utf8)] + internal static partial int mpv_observe_property(nint handle, ulong userData, string name, int format); + [LibraryImport("libmpv.so.2")] + internal static partial Event* mpv_wait_event(nint handle, double timeout); + [LibraryImport("libmpv.so.2")] + internal static partial void mpv_wakeup(nint handle); + [LibraryImport("libmpv.so.2")] + internal static partial void mpv_terminate_destroy(nint handle); + [LibraryImport("libmpv.so.2")] + internal static partial nint mpv_error_string(int error); + [LibraryImport("libmpv.so.2")] + internal static partial void mpv_free(nint data); + [LibraryImport("libmpv.so.2")] + internal static partial int mpv_render_context_create(out nint context, nint handle, RenderParam* parameters); + [LibraryImport("libmpv.so.2")] + internal static partial void mpv_render_context_set_update_callback(nint context, delegate* unmanaged[Cdecl] callback, nint userData); + [LibraryImport("libmpv.so.2")] + internal static partial ulong mpv_render_context_update(nint context); + [LibraryImport("libmpv.so.2")] + internal static partial int mpv_render_context_render(nint context, RenderParam* parameters); + [LibraryImport("libmpv.so.2")] + internal static partial void mpv_render_context_free(nint context); + + [StructLayout(LayoutKind.Sequential)] + internal struct Event + { + internal int Id; + internal int Error; + internal ulong UserData; + internal nint Data; + } + + [StructLayout(LayoutKind.Sequential)] + internal struct EventProperty + { + internal nint Name; + internal int Format; + internal nint Data; + } + + [StructLayout(LayoutKind.Sequential)] + internal struct RenderParam + { + internal int Type; + internal nint Data; + internal RenderParam(int type, nint data) => (Type, Data) = (type, data); + } + } +} diff --git a/src/Files.Platform.Linux/Native/WebKitGtkNative.cs b/src/Files.Platform.Linux/Native/WebKitGtkNative.cs new file mode 100644 index 000000000000..6d57dabae21c --- /dev/null +++ b/src/Files.Platform.Linux/Native/WebKitGtkNative.cs @@ -0,0 +1,139 @@ +// Copyright (c) Files Community +// Licensed under the MIT License. + +using System.Runtime.InteropServices; + +namespace Files.Platform.Linux.Native +{ + // GTK 3 / WebKitGTK 4.1 headers; gboolean and enums are 32-bit, gulong is pointer-sized. + internal static unsafe partial class WebKitGtkNative + { + [LibraryImport("libgtk-3.so.0")] + internal static partial void gtk_disable_setlocale(); + [LibraryImport("libgtk-3.so.0")] + internal static partial int gtk_init_check(nint argc, nint argv); + [LibraryImport("libgtk-3.so.0")] + internal static partial nint gtk_offscreen_window_new(); + [LibraryImport("libgtk-3.so.0")] + internal static partial void gtk_window_set_default_size(nint window, int width, int height); + [LibraryImport("libgtk-3.so.0")] + internal static partial void gtk_container_add(nint container, nint widget); + [LibraryImport("libgtk-3.so.0")] + internal static partial int gtk_widget_get_scale_factor(nint widget); + [LibraryImport("libgtk-3.so.0")] + internal static partial void gtk_widget_show_all(nint widget); + [LibraryImport("libwebkit2gtk-4.1.so.0")] + internal static partial void webkit_web_context_set_sandbox_enabled(nint context, int enabled); + [LibraryImport("libwebkit2gtk-4.1.so.0")] + internal static partial int webkit_web_context_get_sandbox_enabled(nint context); + [LibraryImport("libwebkit2gtk-4.1.so.0")] + internal static partial void webkit_web_view_set_zoom_level(nint view, double zoomLevel); + [LibraryImport("libwebkit2gtk-4.1.so.0")] + internal static partial nint webkit_settings_get_all_features(); + [LibraryImport("libwebkit2gtk-4.1.so.0")] + internal static partial nuint webkit_feature_list_get_length(nint list); + [LibraryImport("libwebkit2gtk-4.1.so.0")] + internal static partial nint webkit_feature_list_get(nint list, nuint index); + [LibraryImport("libwebkit2gtk-4.1.so.0")] + internal static partial void webkit_feature_list_unref(nint list); + [LibraryImport("libwebkit2gtk-4.1.so.0")] + internal static partial nint webkit_feature_get_identifier(nint feature); + [LibraryImport("libwebkit2gtk-4.1.so.0")] + internal static partial void webkit_settings_set_feature_enabled(nint settings, nint feature, int enabled); + [LibraryImport("libgtk-3.so.0")] + internal static partial void gtk_widget_destroy(nint widget); + [LibraryImport("libgtk-3.so.0")] + internal static partial void gtk_main(); + [LibraryImport("libgtk-3.so.0")] + internal static partial void gtk_main_quit(); + + [LibraryImport("libwebkit2gtk-4.1.so.0")] + internal static partial nint webkit_website_data_manager_new_ephemeral(); + [LibraryImport("libwebkit2gtk-4.1.so.0")] + internal static partial nint webkit_web_context_new_with_website_data_manager(nint manager); + [LibraryImport("libwebkit2gtk-4.1.so.0")] + internal static partial void webkit_web_context_set_cache_model(nint context, int model); + [LibraryImport("libwebkit2gtk-4.1.so.0")] + internal static partial nint webkit_web_view_new_with_context(nint context); + [LibraryImport("libwebkit2gtk-4.1.so.0")] + internal static partial nint webkit_settings_new(); + [LibraryImport("libwebkit2gtk-4.1.so.0")] + internal static partial void webkit_web_view_set_settings(nint view, nint settings); + [LibraryImport("libwebkit2gtk-4.1.so.0", StringMarshalling = StringMarshalling.Utf8)] + internal static partial void webkit_web_view_load_html(nint view, string content, string baseUri); + [LibraryImport("libwebkit2gtk-4.1.so.0")] + internal static partial void webkit_web_view_get_snapshot(nint view, int region, int options, nint cancellable, delegate* unmanaged[Cdecl] callback, nint data); + [LibraryImport("libwebkit2gtk-4.1.so.0")] + internal static partial nint webkit_web_view_get_snapshot_finish(nint view, nint result, out nint error); + [LibraryImport("libwebkit2gtk-4.1.so.0")] + internal static partial void webkit_policy_decision_ignore(nint decision); + [LibraryImport("libwebkit2gtk-4.1.so.0")] + internal static partial nint webkit_navigation_policy_decision_get_navigation_action(nint decision); + [LibraryImport("libwebkit2gtk-4.1.so.0")] + internal static partial nint webkit_navigation_action_get_request(nint action); + [LibraryImport("libwebkit2gtk-4.1.so.0")] + internal static partial nint webkit_uri_request_get_uri(nint request); + [LibraryImport("libwebkit2gtk-4.1.so.0")] + internal static partial void webkit_download_cancel(nint download); + [LibraryImport("libwebkit2gtk-4.1.so.0")] + internal static partial void webkit_settings_set_enable_javascript(nint settings, int enabled); + [LibraryImport("libwebkit2gtk-4.1.so.0")] + internal static partial void webkit_settings_set_enable_plugins(nint settings, int enabled); + [LibraryImport("libwebkit2gtk-4.1.so.0")] + internal static partial void webkit_settings_set_enable_webgl(nint settings, int enabled); + [LibraryImport("libwebkit2gtk-4.1.so.0")] + internal static partial void webkit_settings_set_enable_webaudio(nint settings, int enabled); + [LibraryImport("libwebkit2gtk-4.1.so.0")] + internal static partial void webkit_settings_set_enable_media(nint settings, int enabled); + [LibraryImport("libwebkit2gtk-4.1.so.0")] + internal static partial void webkit_settings_set_enable_media_stream(nint settings, int enabled); + [LibraryImport("libwebkit2gtk-4.1.so.0")] + internal static partial void webkit_settings_set_enable_developer_extras(nint settings, int enabled); + [LibraryImport("libwebkit2gtk-4.1.so.0")] + internal static partial void webkit_settings_set_enable_html5_local_storage(nint settings, int enabled); + [LibraryImport("libwebkit2gtk-4.1.so.0")] + internal static partial void webkit_settings_set_enable_html5_database(nint settings, int enabled); + [LibraryImport("libwebkit2gtk-4.1.so.0")] + internal static partial void webkit_settings_set_enable_offline_web_application_cache(nint settings, int enabled); + [LibraryImport("libwebkit2gtk-4.1.so.0")] + internal static partial void webkit_settings_set_enable_page_cache(nint settings, int enabled); + [LibraryImport("libwebkit2gtk-4.1.so.0")] + internal static partial void webkit_settings_set_enable_dns_prefetching(nint settings, int enabled); + [LibraryImport("libwebkit2gtk-4.1.so.0")] + internal static partial void webkit_settings_set_enable_write_console_messages_to_stdout(nint settings, int enabled); + [LibraryImport("libwebkit2gtk-4.1.so.0")] + internal static partial void webkit_settings_set_hardware_acceleration_policy(nint settings, int policy); + + [LibraryImport("libgobject-2.0.so.0", StringMarshalling = StringMarshalling.Utf8)] + internal static partial nuint g_signal_connect_data(nint instance, string signal, nint callback, nint data, nint destroyData, int flags); + [LibraryImport("libgobject-2.0.so.0")] + internal static partial void g_object_unref(nint instance); + + [LibraryImport("libglib-2.0.so.0")] + internal static partial uint g_timeout_add(uint interval, delegate* unmanaged[Cdecl] callback, nint data); + [LibraryImport("libglib-2.0.so.0")] + internal static partial int g_source_remove(uint tag); + [LibraryImport("libglib-2.0.so.0")] + internal static partial void g_error_free(nint error); + + [LibraryImport("libcairo.so.2")] + internal static partial void cairo_surface_flush(nint surface); + [LibraryImport("libcairo.so.2")] + internal static partial void cairo_surface_destroy(nint surface); + [LibraryImport("libcairo.so.2")] + internal static partial int cairo_surface_status(nint surface); + [LibraryImport("libcairo.so.2")] + internal static partial int cairo_surface_get_type(nint surface); + [LibraryImport("libcairo.so.2")] + internal static partial int cairo_image_surface_get_format(nint surface); + [LibraryImport("libcairo.so.2")] + internal static partial int cairo_image_surface_get_width(nint surface); + [LibraryImport("libcairo.so.2")] + internal static partial int cairo_image_surface_get_height(nint surface); + [LibraryImport("libcairo.so.2")] + internal static partial int cairo_image_surface_get_stride(nint surface); + [LibraryImport("libcairo.so.2")] + internal static partial nint cairo_image_surface_get_data(nint surface); + + } +} diff --git a/src/Files.Platform.Linux/Previews/HtmlSnapshotHelper.cs b/src/Files.Platform.Linux/Previews/HtmlSnapshotHelper.cs new file mode 100644 index 000000000000..171f81d3815d --- /dev/null +++ b/src/Files.Platform.Linux/Previews/HtmlSnapshotHelper.cs @@ -0,0 +1,289 @@ +// Copyright (c) Files Community +// Licensed under the MIT License. + +using SkiaSharp; +using System; +using System.Globalization; +using System.IO; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using System.Text; +using static Files.Platform.Linux.Native.WebKitGtkNative; + +namespace Files.Platform.Linux.Previews +{ + /// Runs only in a disposable process, before any Uno or single-instance initialization. + public static unsafe class HtmlSnapshotHelper + { + internal const string Switch = "--linuxfiles-html-snapshot"; + internal const int MaxDocumentBytes = 8 * 1024 * 1024; + internal const int MaxHeight = 8192; + internal const int MaxWidth = 2048; + private const int MinTrimmedHeight = 100; + private static readonly string[] SandboxBypassVariables = ["SNAP", "SNAP_NAME", "SNAP_REVISION", "WEBKIT_DISABLE_SANDBOX_THIS_IS_DANGEROUS"]; + internal const string ScaleVariable = "LINUXFILES_HTML_SNAPSHOT_SCALE"; + + // Resource hints that connect before (or regardless of) the Content-Security-Policy check. + private static readonly string[] DisabledFeatures = ["LinkPreconnect", "LinkPreconnectEarlyHints", "LinkPrefetch", "SpeculationRulesPrefetch"]; + + // The policy precedes all untrusted markup. Additional policies can only restrict it further. + // Keeping the original file URI as the base preserves relative local images and stylesheets. + internal const string DocumentPrefix = "" + + "" + + ""; + + // All callbacks execute on the helper's GTK main thread; one document per process. + private static int resultCode = 1; + private static int maxHeight; + private static int pixelWidth; + private static string initialUri = ""; + private static bool initialNavigation; + private static bool snapshotStarted; + private static bool watchdogExpired; + + public static bool TryRunHelper(string[] args, out int exitCode) + { + exitCode = 1; + if (args.Length == 0 || args[0] != Switch) return false; + if (!TryParseArguments(args, out var path, out var width, out var height)) + { + Console.Error.WriteLine("[Files HTML] Invalid snapshot arguments."); + return true; + } + try { exitCode = Render(path, width, height); } + catch (Exception ex) { Console.Error.WriteLine($"[Files HTML] {ex.Message}"); } + return true; + } + + internal static bool TryParseArguments(string[] args, out string path, out int width, out int height) + { + path = ""; + width = height = 0; + if (args.Length != 4 || args[0] != Switch || !Path.IsPathFullyQualified(args[1]) || args[1].Contains('\0')) return false; + if (!int.TryParse(args[2], NumberStyles.None, CultureInfo.InvariantCulture, out width) || width is < 1 or > MaxWidth || + !int.TryParse(args[3], NumberStyles.None, CultureInfo.InvariantCulture, out height) || height is < 1 or > MaxHeight) return false; + path = args[1]; + return true; + } + + private static int Render(string path, int width, int height) + { + // Pin a regular inode and bound the actual read, including a file growing during the read. + using var source = PreviewFile.OpenRead(path); + if (source.Length > MaxDocumentBytes) throw new IOException("HTML document exceeds 8 MiB."); + using var bounded = new PreviewReadStream(source, MaxDocumentBytes); + using var reader = new StreamReader(bounded, new UTF8Encoding(false, true), detectEncodingFromByteOrderMarks: true); + var html = DocumentPrefix + reader.ReadToEnd(); + initialUri = new Uri(path).AbsoluteUri; + maxHeight = height; + resultCode = 1; + initialNavigation = snapshotStarted = watchdogExpired = false; + + // The web process must run in WebKit's bubblewrap sandbox; without bwrap the source fallback is shown instead. + if (!IsOnPath("bwrap") || !IsOnPath("xdg-dbus-proxy")) throw new IOException("WebKit sandbox tools (bwrap, xdg-dbus-proxy) are unavailable."); + // WebKit silently skips bubblewrap inside Snap and Flatpak, or when told to; refuse instead of rendering unconfined. + if (!IsSandboxEnvironmentSupported(Environment.GetEnvironmentVariable, File.Exists)) throw new IOException("WebKit would run without its sandbox here."); + gtk_disable_setlocale(); + if (gtk_init_check(0, 0) == 0) throw new IOException("No usable GTK display."); + var manager = webkit_website_data_manager_new_ephemeral(); + var context = webkit_web_context_new_with_website_data_manager(manager); + var settings = webkit_settings_new(); + var window = gtk_offscreen_window_new(); + uint watchdog = 0; + try + { + webkit_web_context_set_cache_model(context, 0); // DOCUMENT_VIEWER, no resource cache. + // Must precede the first web view of the context; no extra sandbox paths are granted. + webkit_web_context_set_sandbox_enabled(context, 1); + if (webkit_web_context_get_sandbox_enabled(context) == 0) throw new IOException("WebKit sandbox could not be enabled."); + ConfigureSettings(settings); + var view = webkit_web_view_new_with_context(context); + webkit_web_view_set_settings(view, settings); + // Page zoom renders device pixels on every GDK backend (GDK_SCALE only affects X11) while keeping the CSS width. + var scale = int.TryParse(Environment.GetEnvironmentVariable(ScaleVariable), NumberStyles.None, CultureInfo.InvariantCulture, out var requested) ? Math.Clamp(requested, 1, 2) : 1; + webkit_web_view_set_zoom_level(view, scale); + // The visible region bounds WebKit's snapshot allocation; trailing background rows are trimmed afterwards. + gtk_window_set_default_size(window, width * scale, height); + gtk_container_add(window, view); + g_signal_connect_data(view, "load-changed", (nint)(delegate* unmanaged[Cdecl])&LoadChanged, 0, 0, 0); + g_signal_connect_data(view, "load-failed", (nint)(delegate* unmanaged[Cdecl])&LoadFailed, 0, 0, 0); + g_signal_connect_data(view, "decide-policy", (nint)(delegate* unmanaged[Cdecl])&DecidePolicy, 0, 0, 0); + g_signal_connect_data(view, "web-process-terminated", (nint)(delegate* unmanaged[Cdecl])&ProcessTerminated, 0, 0, 0); + g_signal_connect_data(view, "create", (nint)(delegate* unmanaged[Cdecl])&CreateWindow, 0, 0, 0); + g_signal_connect_data(context, "download-started", (nint)(delegate* unmanaged[Cdecl])&DownloadStarted, 0, 0, 0); + gtk_widget_show_all(window); + pixelWidth = checked(width * scale * gtk_widget_get_scale_factor(view)); + watchdog = g_timeout_add(10_000, &Watchdog, 0); + webkit_web_view_load_html(view, html, initialUri); + gtk_main(); + if (resultCode != 0) Console.Error.WriteLine("[Files HTML] Rendering failed or timed out."); + return resultCode; + } + finally + { + if (watchdog != 0 && !watchdogExpired) g_source_remove(watchdog); + gtk_widget_destroy(window); + g_object_unref(settings); + g_object_unref(context); + g_object_unref(manager); + } + } + + private static void ConfigureSettings(nint settings) + { + webkit_settings_set_enable_javascript(settings, 0); + webkit_settings_set_enable_plugins(settings, 0); + webkit_settings_set_enable_webgl(settings, 0); + webkit_settings_set_enable_webaudio(settings, 0); + webkit_settings_set_enable_media(settings, 0); + webkit_settings_set_enable_media_stream(settings, 0); + webkit_settings_set_enable_developer_extras(settings, 0); + webkit_settings_set_enable_html5_local_storage(settings, 0); + webkit_settings_set_enable_html5_database(settings, 0); + webkit_settings_set_enable_offline_web_application_cache(settings, 0); + webkit_settings_set_enable_page_cache(settings, 0); + webkit_settings_set_enable_dns_prefetching(settings, 0); + webkit_settings_set_enable_write_console_messages_to_stdout(settings, 0); + webkit_settings_set_hardware_acceleration_policy(settings, 2); + DisableFeatures(settings); + } + + // Fails closed: WebKitGTK without the feature API (before 2.42) or without these features is not used. + private static void DisableFeatures(nint settings) + { + var features = webkit_settings_get_all_features(); + if (features == 0) throw new IOException("WebKit feature list unavailable."); + try + { + var preconnectDisabled = false; + var count = webkit_feature_list_get_length(features); + for (nuint i = 0; i < count; i++) + { + var feature = webkit_feature_list_get(features, i); + var identifier = Marshal.PtrToStringUTF8(webkit_feature_get_identifier(feature)); + if (Array.IndexOf(DisabledFeatures, identifier) < 0) continue; + webkit_settings_set_feature_enabled(settings, feature, 0); + preconnectDisabled |= identifier == "LinkPreconnect"; + } + if (!preconnectDisabled) throw new IOException("WebKit preconnect cannot be disabled."); + } + finally { webkit_feature_list_unref(features); } + } + + // Drops trailing rows of the page background (taken from the top-left pixel) below short documents, keeping at least + // minHeight rows. Solid content in another colour, such as a footer, is never trimmed. + internal static int TrimUniformBottom(ReadOnlySpan pixels, int stride, int rowBytes, int height, int minHeight = 1) + { + var last = pixels.Slice((height - 1) * stride, rowBytes); + var background = pixels[..4]; + for (var x = 0; x < rowBytes; x += 4) + if (!last.Slice(x, 4).SequenceEqual(background)) return height; + while (height > Math.Max(1, minHeight) && pixels.Slice((height - 2) * stride, rowBytes).SequenceEqual(last)) height--; + return height; + } + + internal static bool IsSandboxEnvironmentSupported(Func getVariable, Func fileExists) + { + foreach (var name in SandboxBypassVariables) + if (!string.IsNullOrEmpty(getVariable(name))) return false; + return !fileExists("/.flatpak-info"); + } + + private static bool IsOnPath(string name) + { + foreach (var directory in (Environment.GetEnvironmentVariable("PATH") ?? "").Split(':', StringSplitOptions.RemoveEmptyEntries)) + if (Path.IsPathFullyQualified(directory) && File.Exists(Path.Combine(directory, name))) return true; + return false; + } + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] + private static void LoadChanged(nint view, int loadEvent, nint data) + { + if (loadEvent == 0) initialNavigation = true; + if (loadEvent != 3 || snapshotStarted) return; // WEBKIT_LOAD_FINISHED + snapshotStarted = true; + webkit_web_view_get_snapshot(view, 0, 0, 0, &SnapshotReady, 0); // VISIBLE + } + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] + private static int DecidePolicy(nint view, nint decision, int type, nint data) + { + if (type == 2) return 0; // Resource responses; normal rendering, never request a download. + if (type == 0 && !initialNavigation) + { + var action = webkit_navigation_policy_decision_get_navigation_action(decision); + var request = webkit_navigation_action_get_request(action); + if (Marshal.PtrToStringUTF8(webkit_uri_request_get_uri(request)) == initialUri) + { + initialNavigation = true; + return 0; + } + } + webkit_policy_decision_ignore(decision); + return 1; + } + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] + private static nint CreateWindow(nint view, nint action, nint data) => 0; + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] + private static void DownloadStarted(nint context, nint download, nint data) => webkit_download_cancel(download); + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] + private static int LoadFailed(nint view, int loadEvent, nint uri, nint error, nint data) + { + gtk_main_quit(); + return 1; + } + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] + private static void ProcessTerminated(nint view, int reason, nint data) => gtk_main_quit(); + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] + private static int Watchdog(nint data) + { + watchdogExpired = true; + gtk_main_quit(); + return 0; + } + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] + private static void SnapshotReady(nint view, nint result, nint data) + { + nint surface = 0, error = 0; + try + { + surface = webkit_web_view_get_snapshot_finish(view, result, out error); + if (surface == 0 || error != 0 || cairo_surface_status(surface) != 0 || cairo_surface_get_type(surface) != 0) + throw new IOException("No image snapshot."); + cairo_surface_flush(surface); + var format = cairo_image_surface_get_format(surface); + var surfaceWidth = cairo_image_surface_get_width(surface); + var width = pixelWidth; + var height = Math.Min(cairo_image_surface_get_height(surface), maxHeight); + var stride = cairo_image_surface_get_stride(surface); + if (format is not (0 or 1) || width is < 1 or > MaxWidth * 2 || height < 1 || surfaceWidth < width || stride < width * 4 || !BitConverter.IsLittleEndian) + throw new IOException("Unsupported snapshot format or dimensions."); + height = TrimUniformBottom(new ReadOnlySpan((void*)cairo_image_surface_get_data(surface), checked(stride * height)), stride, width * 4, height, MinTrimmedHeight); + // Cairo ARGB32 is native-endian premultiplied BGRA; RGB24 has an unused alpha byte. + using var pixels = new SKPixmap(new SKImageInfo(width, height, SKColorType.Bgra8888, + format == 0 ? SKAlphaType.Premul : SKAlphaType.Opaque), cairo_image_surface_get_data(surface), stride); + using var png = pixels.Encode(SKEncodedImageFormat.Png, 100); + if (png is null || png.Size > HtmlSnapshotRenderer.MaxPngBytes) throw new IOException("Snapshot exceeds output limit."); + using var stdout = Console.OpenStandardOutput(); + png.SaveTo(stdout); + resultCode = 0; + } + catch (Exception ex) { Console.Error.WriteLine($"[Files HTML] {ex.Message}"); } + finally + { + if (error != 0) g_error_free(error); + if (surface != 0) cairo_surface_destroy(surface); + gtk_main_quit(); + } + } + } +} diff --git a/src/Files.Platform.Linux/Previews/HtmlSnapshotRenderer.cs b/src/Files.Platform.Linux/Previews/HtmlSnapshotRenderer.cs new file mode 100644 index 000000000000..da9aa63fefbc --- /dev/null +++ b/src/Files.Platform.Linux/Previews/HtmlSnapshotRenderer.cs @@ -0,0 +1,140 @@ +// Copyright (c) Files Community +// Licensed under the MIT License. + +using System; +using System.ComponentModel; +using System.Diagnostics; +using System.Globalization; +using System.IO; +using System.Runtime.InteropServices; +using System.Threading; +using System.Threading.Tasks; + +namespace Files.Platform.Linux.Previews +{ + /// Isolates GTK, its locale/main loop, and untrusted document rendering from the app. + public static class HtmlSnapshotRenderer + { + internal const int MaxPngBytes = 64 * 1024 * 1024; + private static readonly Lazy available = new(CheckAvailable); + public static bool IsAvailable => available.Value; + + private static bool CheckAvailable() + { + try + { + foreach (var library in new[] { "libwebkit2gtk-4.1.so.0", "libgtk-3.so.0" }) + { + if (!NativeLibrary.TryLoad(library, out var handle)) return false; + NativeLibrary.Free(handle); + } + return true; + } + catch (Exception) { return false; } + } + + public static Task RenderAsync(string path, int cssWidth, int scale, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + if (!Path.IsPathFullyQualified(path) || !IsAvailable || Environment.ProcessPath is not { } processPath) + return Task.FromResult(null); + var start = CreateStartInfo(processPath, Environment.GetCommandLineArgs()[0], path, cssWidth, scale); + return RunProcessAsync(start, cancellationToken); + } + + internal static ProcessStartInfo CreateStartInfo(string processPath, string entryPath, string path, int cssWidth, int scale) + { + var start = new ProcessStartInfo(processPath) + { + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + }; + // The CLR command line supplies the entry DLL without reflection (also safe for NativeAOT). + if (Path.GetFileNameWithoutExtension(processPath) == "dotnet") start.ArgumentList.Add(Path.GetFullPath(entryPath)); + start.ArgumentList.Add(HtmlSnapshotHelper.Switch); + start.ArgumentList.Add(path); + start.ArgumentList.Add(Math.Clamp(cssWidth, 1, HtmlSnapshotHelper.MaxWidth).ToString(CultureInfo.InvariantCulture)); + // The helper's window is this tall, so it also bounds WebKit's snapshot memory. + start.ArgumentList.Add(Math.Min(HtmlSnapshotHelper.MaxHeight, 4096 * Math.Clamp(scale, 1, 2)).ToString(CultureInfo.InvariantCulture)); + start.Environment[HtmlSnapshotHelper.ScaleVariable] = Math.Clamp(scale, 1, 2).ToString(CultureInfo.InvariantCulture); + // The helper applies the scale itself; an inherited session GDK_SCALE would multiply it on X11. + start.Environment["GDK_SCALE"] = "1"; + start.Environment["GDK_DPI_SCALE"] = "1"; + return start; + } + + internal static async Task RunProcessAsync(ProcessStartInfo start, CancellationToken cancellationToken, Action? started = null) + { + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + using var linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeout.Token); + using var process = new Process { StartInfo = start }; + Task? output = null; + Task? errors = null; + try + { + linked.Token.ThrowIfCancellationRequested(); + if (!process.Start()) return null; + // Kill while pipes are being read too, including when a child retains an inherited pipe. + using var registration = linked.Token.Register(() => Kill(process)); + started?.Invoke(process.Id); + output = ReadPngAsync(process.StandardOutput.BaseStream, MaxPngBytes, linked.Token); + errors = DrainErrorsAsync(process.StandardError.BaseStream, linked.Token); + // A cap/format failure must terminate the producer before waiting for its exit. + var png = await output.ConfigureAwait(false); + await process.WaitForExitAsync(linked.Token).ConfigureAwait(false); + await errors.ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + return process.ExitCode == 0 ? png : null; + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) { return null; } + catch (Exception ex) when (ex is IOException or InvalidDataException or Win32Exception or InvalidOperationException) + { + cancellationToken.ThrowIfCancellationRequested(); + return null; + } + finally + { + Kill(process); + linked.Cancel(); + try { if (process.Id > 0) await process.WaitForExitAsync().ConfigureAwait(false); } + catch (InvalidOperationException) { } + // Observe both readers on every exit, without retaining unbounded stderr in memory. + try { if (output is not null) await output.ConfigureAwait(false); } catch (Exception) { } + try { if (errors is not null) await errors.ConfigureAwait(false); } catch (Exception) { } + } + } + + private static void Kill(Process process) + { + try { if (!process.HasExited) process.Kill(entireProcessTree: true); } + catch (Exception ex) when (ex is InvalidOperationException or Win32Exception) { } + } + + internal static bool HasPngSignature(ReadOnlySpan bytes) => bytes.StartsWith((ReadOnlySpan)[137, 80, 78, 71, 13, 10, 26, 10]); + + internal static async Task ReadPngAsync(Stream source, int maxBytes, CancellationToken token) + { + using var result = new MemoryStream(); + var buffer = new byte[8192]; + int count; + while ((count = await source.ReadAsync(buffer, token).ConfigureAwait(false)) != 0) + { + if (result.Length + count > maxBytes) throw new InvalidDataException("HTML snapshot exceeds output limit."); + result.Write(buffer, 0, count); + if (result.Length >= 8 && !HasPngSignature(result.GetBuffer().AsSpan(0, 8))) + throw new InvalidDataException("Invalid HTML snapshot PNG signature."); + } + var bytes = result.ToArray(); + if (!HasPngSignature(bytes)) throw new InvalidDataException("Missing HTML snapshot PNG signature."); + return bytes; + } + + private static async Task DrainErrorsAsync(Stream source, CancellationToken token) + { + var buffer = new byte[4096]; + while (await source.ReadAsync(buffer, token).ConfigureAwait(false) != 0) { } + } + } +} diff --git a/tests/Files.Platform.Tests/Files.Platform.Tests.csproj b/tests/Files.Platform.Tests/Files.Platform.Tests.csproj index 41e04c9b81aa..dda1dcd617c0 100644 --- a/tests/Files.Platform.Tests/Files.Platform.Tests.csproj +++ b/tests/Files.Platform.Tests/Files.Platform.Tests.csproj @@ -11,6 +11,7 @@ x86;x64;arm64 linux-x64;linux-arm64 Exe + Files.Platform.Tests.Program diff --git a/tests/Files.Platform.Tests/Previews/HtmlSnapshotTests.cs b/tests/Files.Platform.Tests/Previews/HtmlSnapshotTests.cs new file mode 100644 index 000000000000..21c54821297c --- /dev/null +++ b/tests/Files.Platform.Tests/Previews/HtmlSnapshotTests.cs @@ -0,0 +1,263 @@ +// Copyright (c) Files Community +// Licensed under the MIT License. + +using Files.Platform.Linux.Previews; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using SkiaSharp; +using System; +using System.Diagnostics; +using System.IO; +using System.Net; +using System.Net.Sockets; +using System.Threading; +using System.Threading.Tasks; + +namespace Files.Platform.Tests.Previews +{ + [TestClass] + public sealed class HtmlSnapshotTests + { + [TestMethod] + [DataRow("/tmp/page.html", "400", "8192", true)] + [DataRow("relative.html", "400", "600", false)] + [DataRow("/tmp/page.html", "0", "600", false)] + [DataRow("/tmp/page.html", "-1", "600", false)] + [DataRow("/tmp/page.html", "2049", "600", false)] + [DataRow("/tmp/page.html", "1.5", "600", false)] + [DataRow("/tmp/page.html", " 400", "600", false)] + [DataRow("/tmp/page.html", "400", "0", false)] + [DataRow("/tmp/page.html", "400", "8193", false)] + [DataRow("/tmp/page.html", "400", "2147483648", false)] + public void ParsesOnlyBoundedAbsoluteArguments(string path, string width, string height, bool valid) + { + Assert.AreEqual(valid, HtmlSnapshotHelper.TryParseArguments([HtmlSnapshotHelper.Switch, path, width, height], out _, out _, out _)); + } + + [TestMethod] + public void RejectsExtraMissingAndUnrecognizedArguments() + { + Assert.IsFalse(HtmlSnapshotHelper.TryParseArguments([HtmlSnapshotHelper.Switch, "/tmp/x", "400", "600", "extra"], out _, out _, out _)); + Assert.IsFalse(HtmlSnapshotHelper.TryParseArguments([HtmlSnapshotHelper.Switch, "/tmp/x", "400"], out _, out _, out _)); + Assert.IsFalse(HtmlSnapshotHelper.TryParseArguments(["--other", "/tmp/x", "400", "600"], out _, out _, out _)); + Assert.IsFalse(HtmlSnapshotHelper.TryRunHelper([], out _)); + Assert.IsFalse(HtmlSnapshotHelper.TryRunHelper(["--other"], out _)); + Assert.IsTrue(HtmlSnapshotHelper.TryRunHelper([HtmlSnapshotHelper.Switch, "relative", "1", "1"], out var code)); + Assert.AreNotEqual(0, code); + } + + [TestMethod] + public async Task PngReaderValidatesSignatureAndExactByteCap() + { + byte[] png = [137, 80, 78, 71, 13, 10, 26, 10, 42]; + Assert.IsTrue(HtmlSnapshotRenderer.HasPngSignature(png)); + Assert.IsFalse(HtmlSnapshotRenderer.HasPngSignature(png.AsSpan(0, 7))); + Assert.IsFalse(HtmlSnapshotRenderer.HasPngSignature("not a PNG"u8)); + using var valid = new MemoryStream(png); + CollectionAssert.AreEqual(png, await HtmlSnapshotRenderer.ReadPngAsync(valid, png.Length, CancellationToken.None)); + using var tooLarge = new MemoryStream(png); + await Assert.ThrowsExactlyAsync(() => HtmlSnapshotRenderer.ReadPngAsync(tooLarge, png.Length - 1, CancellationToken.None)); + using var invalid = new MemoryStream(new byte[9]); + await Assert.ThrowsExactlyAsync(() => HtmlSnapshotRenderer.ReadPngAsync(invalid, 10, CancellationToken.None)); + using var empty = new MemoryStream(); + await Assert.ThrowsExactlyAsync(() => HtmlSnapshotRenderer.ReadPngAsync(empty, 10, CancellationToken.None)); + } + + [TestMethod] + public void TrimsOnlyUniformTrailingRows() + { + // 2-pixel BGRA rows; the page background is the top-left pixel. + byte[] content = [7, 7, 7, 255, 9, 9, 9, 255]; + byte[] background = [7, 7, 7, 255, 7, 7, 7, 255]; + byte[] footer = [0, 0, 255, 255, 0, 0, 255, 255]; + byte[] pixels = [.. content, .. content, .. background, .. background]; + Assert.AreEqual(2 + 1, HtmlSnapshotHelper.TrimUniformBottom(pixels, 8, 8, 4)); + byte[] noisyBottom = [.. content, .. background, .. content]; + Assert.AreEqual(3, HtmlSnapshotHelper.TrimUniformBottom(noisyBottom, 8, 8, 3)); + byte[] blank = [.. background, .. background]; + Assert.AreEqual(1, HtmlSnapshotHelper.TrimUniformBottom(blank, 8, 8, 2)); + byte[] solidFooter = [.. content, .. background, .. footer, .. footer, .. footer]; + Assert.AreEqual(5, HtmlSnapshotHelper.TrimUniformBottom(solidFooter, 8, 8, 5)); + byte[] blankPage = [.. background, .. background, .. background, .. background]; + Assert.AreEqual(2, HtmlSnapshotHelper.TrimUniformBottom(blankPage, 8, 8, 4, minHeight: 2)); + } + + [TestMethod] + [DataRow("SNAP", "/snap/x")] + [DataRow("SNAP_NAME", "x")] + [DataRow("SNAP_REVISION", "1")] + [DataRow("WEBKIT_DISABLE_SANDBOX_THIS_IS_DANGEROUS", "1")] + public void RefusesEnvironmentsWhereWebKitSkipsTheSandbox(string name, string value) + { + Assert.IsFalse(HtmlSnapshotHelper.IsSandboxEnvironmentSupported(n => n == name ? value : null, _ => false)); + Assert.IsTrue(HtmlSnapshotHelper.IsSandboxEnvironmentSupported(_ => null, _ => false)); + Assert.IsFalse(HtmlSnapshotHelper.IsSandboxEnvironmentSupported(_ => null, f => f == "/.flatpak-info")); + } + + [TestMethod] + public void LaunchUsesArgumentListAndDotnetEntryDll() + { + var start = HtmlSnapshotRenderer.CreateStartInfo("/usr/bin/dotnet", "/tmp/Files.dll", "/tmp/file with ' $quotes.html", 320, 2); + Assert.IsFalse(start.UseShellExecute); + CollectionAssert.AreEqual(new[] { "/tmp/Files.dll", HtmlSnapshotHelper.Switch, "/tmp/file with ' $quotes.html", "320", "8192" }, new System.Collections.Generic.List(start.ArgumentList)); + Assert.AreEqual("2", start.Environment[HtmlSnapshotHelper.ScaleVariable]); + Assert.AreEqual("1", start.Environment["GDK_SCALE"]); + var native = HtmlSnapshotRenderer.CreateStartInfo("/opt/Files", "/opt/Files", "/tmp/x", 320, 1); + Assert.AreEqual(HtmlSnapshotHelper.Switch, native.ArgumentList[0]); + } + + [TestMethod] + [DataRow(1)] + [DataRow(2)] + public async Task RendersRequestedWidthAndDisablesJavaScript(int scale) + { + RequireIntegration(); + var root = Directory.CreateTempSubdirectory("linuxfiles-html-").FullName; + try + { + var path = Path.Combine(root, "script test.html"); + await File.WriteAllTextAsync(path, ""); + var png = await HtmlSnapshotRenderer.RenderAsync(path, 320, scale, CancellationToken.None); + Assert.IsNotNull(png, "Snapshot helper failed."); + using var bitmap = SKBitmap.Decode(png); + Assert.IsNotNull(bitmap); + Assert.AreEqual(320 * scale, bitmap.Width); + var color = bitmap.GetPixel(bitmap.Width / 2, bitmap.Height / 2); + Assert.AreEqual((byte)0, color.Red); + Assert.AreEqual((byte)255, color.Green); + } + finally { Directory.Delete(root, true); } + } + + [TestMethod] + public async Task BlocksNetworkImagesAndStylesIncludingLocalCssImports() + { + RequireIntegration(); + var root = Directory.CreateTempSubdirectory("linuxfiles-html-network-").FullName; + using var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + using var stop = new CancellationTokenSource(); + var accepted = 0; + var accepting = Task.Run(async () => + { + try + { + while (!stop.IsCancellationRequested) + { + using var client = await listener.AcceptTcpClientAsync(stop.Token); + Interlocked.Increment(ref accepted); + } + } + catch (OperationCanceledException) { } + }); + try + { + var url = $"http://127.0.0.1:{((IPEndPoint)listener.LocalEndpoint).Port}"; + await File.WriteAllTextAsync(Path.Combine(root, "local.css"), $"@import url('{url}/nested.css'); body {{background:rgb(0,255,0)}}"); + var path = Path.Combine(root, "network.html"); + await File.WriteAllTextAsync(path, $"" + + $"" + + $"" + + $""); + var png = await HtmlSnapshotRenderer.RenderAsync(path, 320, 1, CancellationToken.None); + Assert.IsNotNull(png, "Snapshot helper failed."); + using var bitmap = SKBitmap.Decode(png); + Assert.IsNotNull(bitmap); + Assert.AreEqual(SKColors.Lime, bitmap.GetPixel(160, 50), "Relative local stylesheet must still load."); + await Task.Delay(150); + Assert.AreEqual(0, Volatile.Read(ref accepted), "Local HTML made a network connection."); + } + finally + { + stop.Cancel(); + await accepting; + Directory.Delete(root, true); + } + } + + [TestMethod] + public async Task CancellationTerminatesTheHelperProcess() + { + RequireIntegration(); + var root = Directory.CreateTempSubdirectory("linuxfiles-html-cancel-").FullName; + try + { + var path = Path.Combine(root, "cancel.html"); + await File.WriteAllTextAsync(path, "

Cancel

"); + using var cancel = new CancellationTokenSource(); + Process? helper = null; + try + { + var start = HtmlSnapshotRenderer.CreateStartInfo(Environment.ProcessPath!, Environment.GetCommandLineArgs()[0], path, 320, 1); + await Assert.ThrowsAsync(() => HtmlSnapshotRenderer.RunProcessAsync(start, cancel.Token, pid => + { + helper = Process.GetProcessById(pid); + cancel.Cancel(); + })); + Assert.IsNotNull(helper); + Assert.IsTrue(helper.HasExited, "Renderer must reap its helper before completing cancellation."); + } + finally { helper?.Dispose(); } + } + finally { Directory.Delete(root, true); } + } + + [TestMethod] + public async Task CropsFullDocumentToMaximumPixelHeight() + { + RequireIntegration(); + var root = Directory.CreateTempSubdirectory("linuxfiles-html-crop-").FullName; + try + { + var path = Path.Combine(root, "tall.html"); + await File.WriteAllTextAsync(path, "
"); + var start = HtmlSnapshotRenderer.CreateStartInfo(Environment.ProcessPath!, Environment.GetCommandLineArgs()[0], path, 320, 1); + start.ArgumentList[^1] = "700"; + var png = await HtmlSnapshotRenderer.RunProcessAsync(start, CancellationToken.None); + Assert.IsNotNull(png); + using var bitmap = SKBitmap.Decode(png); + Assert.IsNotNull(bitmap); + Assert.AreEqual(320, bitmap.Width); + Assert.AreEqual(700, bitmap.Height); + } + finally { Directory.Delete(root, true); } + } + + [TestMethod] + public async Task RefusesToRenderWithoutTheWebKitSandboxTools() + { + var root = Directory.CreateTempSubdirectory("linuxfiles-html-nosandbox-").FullName; + try + { + var path = Path.Combine(root, "page.html"); + await File.WriteAllTextAsync(path, "

Sandbox

"); + // The check runs before GTK is initialized, so no display is needed. + var start = HtmlSnapshotRenderer.CreateStartInfo(Environment.ProcessPath!, Environment.GetCommandLineArgs()[0], path, 320, 1); + start.Environment["PATH"] = root; + Assert.IsNull(await HtmlSnapshotRenderer.RunProcessAsync(start, CancellationToken.None)); + } + finally { Directory.Delete(root, true); } + } + + [TestMethod] + public async Task RejectsOversizedDocumentsInHelper() + { + var root = Directory.CreateTempSubdirectory("linuxfiles-html-limit-").FullName; + try + { + var path = Path.Combine(root, "large.html"); + await using (var file = File.Create(path)) file.SetLength(HtmlSnapshotHelper.MaxDocumentBytes + 1L); + // Rejection happens before GTK is initialized, so this also tests self-launch without a display. + var start = HtmlSnapshotRenderer.CreateStartInfo(Environment.ProcessPath!, Environment.GetCommandLineArgs()[0], path, 320, 1); + Assert.IsNull(await HtmlSnapshotRenderer.RunProcessAsync(start, CancellationToken.None)); + } + finally { Directory.Delete(root, true); } + } + + private static void RequireIntegration() + { + if (!HtmlSnapshotRenderer.IsAvailable || + (string.IsNullOrEmpty(Environment.GetEnvironmentVariable("WAYLAND_DISPLAY")) && string.IsNullOrEmpty(Environment.GetEnvironmentVariable("DISPLAY")))) + Assert.Inconclusive("HTML snapshot integration requires WebKitGTK 4.1, GTK3 and a private usable display."); + } + } +} diff --git a/tests/Files.Platform.Tests/Previews/MpvMediaPlayerTests.cs b/tests/Files.Platform.Tests/Previews/MpvMediaPlayerTests.cs new file mode 100644 index 000000000000..5fd7456ae5a7 --- /dev/null +++ b/tests/Files.Platform.Tests/Previews/MpvMediaPlayerTests.cs @@ -0,0 +1,244 @@ +// Copyright (c) Files Community +// Licensed under the MIT License. + +using Files.Platform.Linux.Media; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace Files.Platform.Tests.Previews +{ + [TestClass] + public sealed class MpvMediaPlayerTests + { + [TestMethod] + [DataRow(640, 360, 640, 480, 480, 360)] + [DataRow(5000, 5000, 3840, 2160, 1920, 1080)] + [DataRow(1920, 1080, 1080, 1920, 608, 1080)] + [DataRow(0, -1, 64, 48, 1, 1)] + [DataRow(640, 360, 0, 0, 640, 360)] + [DataRow(int.MaxValue, int.MaxValue, int.MaxValue, 1, 1920, 1)] + public void FitsAspectRatioWithinBoundedTarget(int width, int height, int videoWidth, int videoHeight, int expectedWidth, int expectedHeight) + { + Assert.AreEqual((expectedWidth, expectedHeight), MpvMediaPlayer.FitSize(width, height, videoWidth, videoHeight)); + } + + [TestMethod] + public void OptionsDisableExternalInputsAndPlaylistDemuxers() + { + var options = MpvMediaPlayer.Options.ToArray().ToDictionary(p => p.Name, p => p.Value); + foreach (var name in new[] { "config", "load-scripts", "ytdl", "access-references", "load-unsafe-playlists", "autoload-files", "sub-auto", "audio-file-auto", "cover-art-auto", "autocreate-playlist", "prefetch-playlist", "ordered-chapters" }) + Assert.AreEqual("no", options[name], name); + Assert.AreEqual("lavf", options["demuxer"]); + StringAssert.StartsWith(options["demuxer-lavf-o"], "protocol_whitelist=[file],format_whitelist=["); + foreach (var format in new[] { "hls", "dash", "concat", "sdp" }) + Assert.IsFalse(options["demuxer-lavf-o"].Contains(format, StringComparison.Ordinal)); + Assert.AreEqual("protocol_whitelist=[file]", options["stream-lavf-o"]); + Assert.AreEqual("libmpv", options["vo"]); + Assert.AreEqual("no", options["hwdec"]); + Assert.AreEqual("yes", options["pause"]); + } + + [TestMethod] + public async Task RejectsRelativePathsWithoutLoadingNativeCode() + { + using var player = new MpvMediaPlayer(); + Assert.ThrowsExactly(() => player.Open("relative.mp4")); + player.Dispose(); + player.Dispose(); + await player.Disposal.WaitAsync(TimeSpan.FromSeconds(10)); + } + + [TestMethod] + public async Task RejectsNonRegularFilesBeforeLoadingNativeCode() + { + var root = Directory.CreateTempSubdirectory("linuxfiles-mpv-fifo-").FullName; + try + { + var fifo = Path.Combine(root, "pipe.mp4"); + using (var mkfifo = Process.Start(new ProcessStartInfo("mkfifo", [fifo]) { UseShellExecute = false })!) + { + await mkfifo.WaitForExitAsync(); + Assert.AreEqual(0, mkfifo.ExitCode); + } + using var player = new MpvMediaPlayer(); + Assert.ThrowsExactly(() => player.Open(fifo)); + await player.Disposal.WaitAsync(TimeSpan.FromSeconds(10)); + } + finally { Directory.Delete(root, true); } + } + + [TestMethod] + public async Task RendersOpaqueOwnedFramesAndResizesWhilePaused() + { + RequireIntegration(); + var root = Directory.CreateTempSubdirectory("linuxfiles-mpv-").FullName; + using var player = new MpvMediaPlayer(); + try + { + var path = await CreateVideo(root); + player.SetTargetSize(128, 96); + var first = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var resized = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + player.FrameRendered += frame => + { + if (frame.Width == 128 && frame.Height == 96) first.TrySetResult(frame); + if (frame.Width == 80 && frame.Height == 60) resized.TrySetResult(frame); + }; + player.Open(path); + var frame = await first.Task.WaitAsync(TimeSpan.FromSeconds(15)); + Assert.IsNull(player.Error); + Assert.IsTrue(player.IsPaused); + Assert.IsTrue(player.HasVideo); + Assert.AreEqual(64, player.VideoWidth); + Assert.AreEqual(48, player.VideoHeight); + Assert.AreEqual(frame.Width * 4, frame.Stride); + AssertOpaque(frame); + Assert.IsTrue(frame.Pixels.ToArray().Where((_, i) => i % 4 != 3).Distinct().Count() > 5, "Expected a decoded test pattern."); + var retained = frame.Pixels.ToArray(); + player.SetTargetSize(80, 60); + AssertOpaque(await resized.Task.WaitAsync(TimeSpan.FromSeconds(10))); + player.Play(); + await WaitUntil(() => player.IsEnded, player); + Assert.IsTrue(player.Duration.TotalSeconds > 0); + player.Seek(TimeSpan.Zero); + player.Pause(); + player.Dispose(); + await player.Disposal.WaitAsync(TimeSpan.FromSeconds(10)); + CollectionAssert.AreEqual(retained, frame.Pixels.ToArray()); + } + finally + { + player.Dispose(); + await player.Disposal.WaitAsync(TimeSpan.FromSeconds(10)); + Directory.Delete(root, true); + } + } + + [TestMethod] + public async Task DisposeDuringPlaybackAndDoubleDisposeComplete() + { + RequireIntegration(); + var root = Directory.CreateTempSubdirectory("linuxfiles-mpv-").FullName; + using var player = new MpvMediaPlayer(); + try + { + var path = await CreateVideo(root); + player.Open(path); + player.Play(); // Also exercises a play request made before FILE_LOADED. + await WaitUntil(() => player.Position > TimeSpan.Zero, player); + var stopwatch = Stopwatch.StartNew(); + player.Dispose(); + Assert.IsLessThan(TimeSpan.FromSeconds(1), stopwatch.Elapsed); + await Task.WhenAll(Task.Run(player.Dispose), Task.Run(player.Dispose)); + await player.Disposal.WaitAsync(TimeSpan.FromSeconds(10)); + Assert.ThrowsExactly(player.Play); + } + finally + { + player.Dispose(); + await player.Disposal.WaitAsync(TimeSpan.FromSeconds(10)); + Directory.Delete(root, true); + } + } + + [TestMethod] + public async Task DisposeFromFrameCallbackCompletes() + { + RequireIntegration(); + var root = Directory.CreateTempSubdirectory("linuxfiles-mpv-").FullName; + using var player = new MpvMediaPlayer(); + try + { + var path = await CreateVideo(root); + var rendered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + player.FrameRendered += frame => + { + player.Dispose(); + rendered.TrySetResult(frame); + }; + player.Open(path); + var frame = await rendered.Task.WaitAsync(TimeSpan.FromSeconds(15)); + await player.Disposal.WaitAsync(TimeSpan.FromSeconds(10)); + AssertOpaque(frame); + } + finally + { + player.Dispose(); + await player.Disposal.WaitAsync(TimeSpan.FromSeconds(10)); + Directory.Delete(root, true); + } + } + + [TestMethod] + [DataRow("#EXTM3U\nhttp://127.0.0.1:9/should-not-open.mp4\n")] + [DataRow("[playlist]\nNumberOfEntries=1\nFile1=http://127.0.0.1:9/should-not-open.mp4\n")] + [DataRow("#EXTM3U\n#EXT-X-TARGETDURATION:1\n#EXTINF:1,\nhttp://127.0.0.1:9/should-not-open.ts\n#EXT-X-ENDLIST\n")] + [DataRow("#EXTM3U\n#EXT-X-TARGETDURATION:1\n#EXTINF:1,\ntest video.mp4\n#EXT-X-ENDLIST\n")] + [DataRow("ffconcat version 1.0\nfile 'test video.mp4'\n")] + public async Task DisguisedPlaylistFailsWithoutPublishingFrames(string content) + { + RequireIntegration(); + var root = Directory.CreateTempSubdirectory("linuxfiles-mpv-").FullName; + using var player = new MpvMediaPlayer(); + try + { + if (content.Contains("test video.mp4", StringComparison.Ordinal)) await CreateVideo(root); + var path = Path.Combine(root, "playlist.mp4"); + await File.WriteAllTextAsync(path, content); + var frames = 0; + player.FrameRendered += _ => Interlocked.Increment(ref frames); + player.Open(path); + player.Play(); + await WaitUntil(() => player.Error is not null, player, allowFailure: true); + Assert.IsNotNull(player.Error); + Assert.AreEqual(0, Volatile.Read(ref frames)); + Assert.IsTrue(player.IsPaused); + } + finally + { + player.Dispose(); + await player.Disposal.WaitAsync(TimeSpan.FromSeconds(10)); + Directory.Delete(root, true); + } + } + + private static void RequireIntegration() + { + if (!MpvMediaPlayer.IsAvailable || !File.Exists("/usr/bin/ffmpeg")) + Assert.Inconclusive("Media integration requires libmpv client API 2 and ffmpeg."); + } + + private static async Task CreateVideo(string root) + { + var path = Path.Combine(root, "test video.mp4"); + using var process = Process.Start(new ProcessStartInfo("/usr/bin/ffmpeg", + ["-nostdin", "-loglevel", "error", "-y", "-filter_threads", "1", "-f", "lavfi", "-i", "testsrc=size=64x48:rate=10:duration=1", "-c:v", "mpeg4", "-threads", "1", "-pix_fmt", "yuv420p", path]) { UseShellExecute = false })!; + try { await process.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(15)); } + finally { if (!process.HasExited) { process.Kill(true); await process.WaitForExitAsync(); } } + Assert.AreEqual(0, process.ExitCode, "ffmpeg test video generation failed."); + return path; + } + + private static void AssertOpaque(MpvFrame frame) + { + Assert.AreEqual(frame.Stride * frame.Height, frame.Pixels.Length); + for (var i = 3; i < frame.Pixels.Length; i += 4) Assert.AreEqual((byte)255, frame.Pixels.Span[i]); + } + + private static async Task WaitUntil(Func condition, MpvMediaPlayer player, bool allowFailure = false) + { + var deadline = Stopwatch.StartNew(); + while (!condition()) + { + if (!allowFailure) Assert.IsNull(player.Error); + if (deadline.Elapsed > TimeSpan.FromSeconds(15)) Assert.Fail("Timed out waiting for media state: " + player.Error); + await Task.Delay(20); + } + } + } +} diff --git a/tests/Files.Platform.Tests/Program.cs b/tests/Files.Platform.Tests/Program.cs new file mode 100644 index 000000000000..30dd6528038f --- /dev/null +++ b/tests/Files.Platform.Tests/Program.cs @@ -0,0 +1,17 @@ +// Copyright (c) Files Community +// Licensed under the MIT License. + +using Files.Platform.Linux.Previews; +using System.Threading.Tasks; + +namespace Files.Platform.Tests +{ + internal static class Program + { + public static Task Main(string[] args) + { + if (HtmlSnapshotHelper.TryRunHelper(args, out var exitCode)) return Task.FromResult(exitCode); + return MicrosoftTestingPlatformApplication.RunAsync(args); + } + } +}