From e8dd8cd149dd7da78f689f0e6eedf7cb3a1b794e Mon Sep 17 00:00:00 2001 From: d2dyno <53011783+d2dyno1@users.noreply.github.com> Date: Fri, 17 Jul 2026 13:28:43 +0200 Subject: [PATCH 1/4] New onboarding experience on Uno --- src/Platforms/SecureFolderFS.UI/Constants.cs | 131 ---------- .../Introduction/EncryptedFileSlide.xaml | 12 +- .../Introduction/EncryptedFileSlide.xaml.cs | 124 ++++++--- .../Introduction/IntroductionBackground.cs | 159 ++++++++++++ .../IntroductionBackgroundRenderer.cs | 237 ++++++++++++++++++ .../Introduction/IntroductionControl.xaml | 146 ++++++----- .../Introduction/IntroductionControl.xaml.cs | 32 +-- 7 files changed, 575 insertions(+), 266 deletions(-) create mode 100644 src/Platforms/SecureFolderFS.Uno/UserControls/Introduction/IntroductionBackground.cs create mode 100644 src/Platforms/SecureFolderFS.Uno/UserControls/Introduction/IntroductionBackgroundRenderer.cs diff --git a/src/Platforms/SecureFolderFS.UI/Constants.cs b/src/Platforms/SecureFolderFS.UI/Constants.cs index ecdd20576..4b93f0bdb 100644 --- a/src/Platforms/SecureFolderFS.UI/Constants.cs +++ b/src/Platforms/SecureFolderFS.UI/Constants.cs @@ -57,136 +57,5 @@ public static class FileData FolderType = Generic """; } - - public static class Introduction - { - public const string BACKGROUND_WEBVIEW = """ - - - - - - Fragment Shader - - - - - - - - """; - } } } diff --git a/src/Platforms/SecureFolderFS.Uno/UserControls/Introduction/EncryptedFileSlide.xaml b/src/Platforms/SecureFolderFS.Uno/UserControls/Introduction/EncryptedFileSlide.xaml index 8ede94e6f..abdf2e873 100644 --- a/src/Platforms/SecureFolderFS.Uno/UserControls/Introduction/EncryptedFileSlide.xaml +++ b/src/Platforms/SecureFolderFS.Uno/UserControls/Introduction/EncryptedFileSlide.xaml @@ -6,24 +6,20 @@ xmlns:l="using:SecureFolderFS.Uno.Localization" xmlns:local="using:SecureFolderFS.Uno.UserControls.Introduction" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" - xmlns:skia="using:SkiaSharp.Views.Windows" mc:Ignorable="d"> + - - + PointerReleased="SlotGrid_PointerReleased" /> diff --git a/src/Platforms/SecureFolderFS.Uno/UserControls/Introduction/EncryptedFileSlide.xaml.cs b/src/Platforms/SecureFolderFS.Uno/UserControls/Introduction/EncryptedFileSlide.xaml.cs index eb4c47bae..99020b7de 100644 --- a/src/Platforms/SecureFolderFS.Uno/UserControls/Introduction/EncryptedFileSlide.xaml.cs +++ b/src/Platforms/SecureFolderFS.Uno/UserControls/Introduction/EncryptedFileSlide.xaml.cs @@ -10,7 +10,12 @@ using SecureFolderFS.UI.Enums; using SecureFolderFS.Uno.Helpers; using SkiaSharp; +#if HAS_UNO_SKIA +using Windows.Foundation; +using Uno.WinUI.Graphics2DSK; +#else using SkiaSharp.Views.Windows; +#endif namespace SecureFolderFS.Uno.UserControls.Introduction { @@ -37,10 +42,21 @@ public sealed partial class EncryptedFileSlide : UserControl, IAsyncInitialize, private bool _isInitialized; +#if HAS_UNO_SKIA + private readonly LensCanvas _canvas; +#else + private readonly SKXamlCanvas _canvas; +#endif + private SKBitmap? _wallpaperBitmap; private SKBitmap? _hexBitmap; private SKPoint? _pointerPosition; + // The lens samples the composed scene, so the base layers are rendered into an + // offscreen surface whose snapshot can be read back (the on-screen canvas cannot be) + private SKSurface? _sceneSurface; + private SKSizeI _sceneSize; + // Reusable resources for better performance private readonly SKPaint _highlightPaint; private readonly SKPaint _shadowPaint; @@ -75,6 +91,18 @@ public EncryptedFileSlide() { InitializeComponent(); +#if HAS_UNO_SKIA + // SKCanvasElement renders in the app's composition pass (GPU-backed, no + // per-frame bitmap upload), unlike SKXamlCanvas which lags on desktop + _canvas = new LensCanvas(this); +#else + _canvas = new SKXamlCanvas(); + _canvas.PaintSurface += SkiaCanvas_PaintSurface; +#endif + _canvas.HorizontalAlignment = HorizontalAlignment.Stretch; + _canvas.VerticalAlignment = VerticalAlignment.Stretch; + SlotGrid.Children.Add(_canvas); + // Pre-create expensive paint objects _blurPaint = new SKPaint { @@ -167,7 +195,7 @@ public Task InitAsync(CancellationToken cancellationToken = default) _cachedEdgeColors = null; _cachedSweepStops = null; _cachedCoreColors = null; - SkiaCanvas.Invalidate(); + _canvas.Invalidate(); _isInitialized = true; return Task.CompletedTask; @@ -215,33 +243,65 @@ private static SKBitmap FlipBitmap(SKBitmap src, bool horizontal, bool vertical) return result; } +#if HAS_UNO_SKIA + private sealed class LensCanvas(EncryptedFileSlide owner) : SKCanvasElement + { + protected override void RenderOverride(SKCanvas canvas, Size area) + { + // Work in physical pixels like SKXamlCanvas did, so that all the tuned + // radii, stroke widths, and blur sigmas keep their meaning + var scale = (float)(owner.XamlRoot?.RasterizationScale ?? 1.0); + canvas.Save(); + canvas.Scale(1f / scale); + owner.RenderSlide(canvas, (float)area.Width * scale, (float)area.Height * scale); + canvas.Restore(); + } + } +#else private void SkiaCanvas_PaintSurface(object sender, SKPaintSurfaceEventArgs e) { var canvas = e.Surface.Canvas; - var info = e.Info; canvas.Clear(SKColors.Transparent); + RenderSlide(canvas, e.Info.Width, e.Info.Height); + } +#endif + + private void RenderSlide(SKCanvas canvas, float width, float height) + { + if (width < 1f || height < 1f) + return; + + var center = _smoothPosition != default ? _smoothPosition : _pointerPosition ?? new SKPoint(width / 2f, height / 2f); + var sceneSize = new SKSizeI((int)width, (int)height); + if (_sceneSurface is null || sceneSize != _sceneSize) + { + _sceneSurface?.Dispose(); + _sceneSurface = SKSurface.Create(new SKImageInfo(sceneSize.Width, sceneSize.Height)); + _sceneSize = sceneSize; + } - var center = _smoothPosition != default ? _smoothPosition : _pointerPosition ?? new SKPoint(info.Width / 2f, info.Height / 2f); - var canvasRect = new SKRect(0, 0, info.Width, info.Height); + var scene = _sceneSurface.Canvas; + scene.Clear(SKColors.Transparent); + var sceneRect = new SKRect(0, 0, width, height); // Layer 1: Hex (encrypted content) if (_hexBitmap is not null) { - var hexDest = ComputeUniformToFillRect(_hexBitmap.Width, _hexBitmap.Height, info.Width, info.Height); - canvas.Save(); - canvas.ClipRect(canvasRect); - canvas.DrawBitmap(_hexBitmap, hexDest); - canvas.Restore(); + var hexDest = ComputeUniformToFillRect(_hexBitmap.Width, _hexBitmap.Height, sceneSize.Width, sceneSize.Height); + scene.Save(); + scene.ClipRect(sceneRect); + scene.DrawBitmap(_hexBitmap, hexDest); + scene.Restore(); } // Layer 2: Wallpaper + smooth reveal hole if (_wallpaperBitmap is not null) { var wallpaperDest = ComputeUniformToFillRect(_wallpaperBitmap.Width, _wallpaperBitmap.Height, - info.Width, info.Height); + sceneSize.Width, sceneSize.Height); - canvas.SaveLayer(); - canvas.DrawBitmap(_wallpaperBitmap, wallpaperDest); + scene.SaveLayer(); + scene.DrawBitmap(_wallpaperBitmap, wallpaperDest); using var erasePaint = new SKPaint(); erasePaint.IsAntialias = true; @@ -251,12 +311,13 @@ private void SkiaCanvas_PaintSurface(object sender, SKPaintSurfaceEventArgs e) [0.2f, 0.4f, 1f], SKShaderTileMode.Clamp); - canvas.DrawCircle(center, MAGNIFIER_RADIUS, erasePaint); - canvas.Restore(); + scene.DrawCircle(center, MAGNIFIER_RADIUS, erasePaint); + scene.Restore(); } // Snapshot for the lens effect (only once per frame) - using var snapshot = e.Surface.Snapshot(); + using var snapshot = _sceneSurface.Snapshot(); + canvas.DrawImage(snapshot, 0f, 0f); // Layer 3: Liquid Glass Lens DrawLiquidGlassLens(canvas, center, snapshot, _lensScale); @@ -584,7 +645,7 @@ private void AnimTimer_Tick(object? sender, object e) if (moved) _cachedEdgeColors = null; - SkiaCanvas.Invalidate(); + _canvas.Invalidate(); } private void StartAnimation() @@ -643,7 +704,7 @@ private void SlotGrid_PointerExited(object sender, PointerRoutedEventArgs e) _scaleTarget = 1f; _positionVelocity = new SKPoint(0f, 0f); StopAnimation(); - SkiaCanvas.Invalidate(); + _canvas.Invalidate(); } private void UpdatePointer(PointerRoutedEventArgs e) @@ -655,8 +716,14 @@ private void UpdatePointer(PointerRoutedEventArgs e) if (width <= 0 || height <= 0) return; - var scaleX = SkiaCanvas.CanvasSize.Width / width; - var scaleY = SkiaCanvas.CanvasSize.Height / height; +#if HAS_UNO_SKIA + // The lens is rendered in physical pixels; map the logical pointer position accordingly + var scaleX = (float)(XamlRoot?.RasterizationScale ?? 1.0); + var scaleY = scaleX; +#else + var scaleX = _canvas.CanvasSize.Width / width; + var scaleY = _canvas.CanvasSize.Height / height; +#endif var clampedX = Math.Clamp((float)pos.X, MAGNIFIER_RADIUS / scaleX, width - MAGNIFIER_RADIUS / scaleX); var clampedY = Math.Clamp((float)pos.Y, MAGNIFIER_RADIUS / scaleY, height - MAGNIFIER_RADIUS / scaleY); @@ -678,19 +745,14 @@ private void UpdatePointer(PointerRoutedEventArgs e) _animTimer.Stop(); _animTimer.Tick -= AnimTimer_Tick; -#if HAS_UNO_SKIA - SkiaCanvas.Dispose(); -#endif - - _blurPaint.Dispose(); - _highlightPaint.Dispose(); - _shadowPaint.Dispose(); + // Detach the canvas so no further composition pass can render this slide. + // The Skia resources (paints, bitmaps, scene surface) are intentionally not + // disposed here: a redraw may already be queued, and the GC reclaims them safely. + SlotGrid.Children.Remove(_canvas); - _invisiblePaint.Dispose(); - _outerGlowPaint.Dispose(); - _iridescentPaint.Dispose(); - _corePaint.Dispose(); - _additiveGlowPaint.Dispose(); + _wallpaperBitmap = null; + _hexBitmap = null; + _sceneSurface = null; _cachedEdgeColors = null; _cachedSweepStops = null; diff --git a/src/Platforms/SecureFolderFS.Uno/UserControls/Introduction/IntroductionBackground.cs b/src/Platforms/SecureFolderFS.Uno/UserControls/Introduction/IntroductionBackground.cs new file mode 100644 index 000000000..2bd9e5a7c --- /dev/null +++ b/src/Platforms/SecureFolderFS.Uno/UserControls/Introduction/IntroductionBackground.cs @@ -0,0 +1,159 @@ +using System; +using System.Diagnostics; +using System.Threading.Tasks; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +#if HAS_UNO_SKIA +using Windows.Foundation; +using SkiaSharp; +using Uno.WinUI.Graphics2DSK; +#else +using SkiaSharp.Views.Windows; +#endif + +namespace SecureFolderFS.Uno.UserControls.Introduction +{ + /// + /// An animated, grainy mesh-gradient background. See + /// for the drawing logic; this control hosts it on the fastest canvas available per platform. + /// + public sealed partial class IntroductionBackground : UserControl, IDisposable + { + private const double REVEAL_DURATION_MS = 750d; + +#if HAS_UNO_SKIA + // SKCanvasElement draws directly in the app's composition pass (GPU-backed, + // no per-frame bitmap upload), so a full frame rate is affordable + private const int FRAME_INTERVAL_MS = 16; + private readonly CompositionCanvas _canvas; +#else + // SKXamlCanvas rasterizes on the CPU and uploads the bitmap each frame - keep + // the cadence lower and let the renderer use cheaper sampling + private const int FRAME_INTERVAL_MS = 33; + private readonly SKXamlCanvas _canvas; +#endif + + private readonly IntroductionBackgroundRenderer _renderer = new(); + private readonly DispatcherTimer _frameTimer; + private readonly Stopwatch _clock = Stopwatch.StartNew(); + + private float _revealProgress; + private DateTime? _revealStart; + private TaskCompletionSource? _revealTcs; + + public IntroductionBackground() + { + IsHitTestVisible = false; + +#if HAS_UNO_SKIA + _canvas = new CompositionCanvas(this); +#else + _renderer.HighQualitySampling = false; + _canvas = new SKXamlCanvas(); + _canvas.PaintSurface += Canvas_PaintSurface; +#endif + _canvas.HorizontalAlignment = HorizontalAlignment.Stretch; + _canvas.VerticalAlignment = VerticalAlignment.Stretch; + Content = _canvas; + + _frameTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(FRAME_INTERVAL_MS) }; + _frameTimer.Tick += FrameTimer_Tick; + + Loaded += IntroductionBackground_Loaded; + Unloaded += IntroductionBackground_Unloaded; + ActualThemeChanged += IntroductionBackground_ActualThemeChanged; + } + + /// + /// Sweeps the background into view with a bottom-up gradient wipe. + /// + /// A that completes when the background is fully revealed. + public Task RevealAsync() + { + if (_revealTcs is not null) + return _revealTcs.Task; + + _revealTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + _revealStart = DateTime.UtcNow; + _frameTimer.Start(); + + return _revealTcs.Task; + } + + private void FrameTimer_Tick(object? sender, object e) + { + if (_revealStart is { } revealStart) + { + var progress = (float)((DateTime.UtcNow - revealStart).TotalMilliseconds / REVEAL_DURATION_MS); + if (progress >= 1f) + { + _revealProgress = 1f; + _revealStart = null; + _revealTcs?.TrySetResult(); + } + else + _revealProgress = 1f - MathF.Pow(1f - progress, 3f); // ease-out cubic + } + + _canvas.Invalidate(); + } + +#if HAS_UNO_SKIA + private sealed class CompositionCanvas(IntroductionBackground owner) : SKCanvasElement + { + protected override void RenderOverride(SKCanvas canvas, Size area) + { + owner._renderer.Render( + canvas, + (float)area.Width, + (float)area.Height, + (float)owner._clock.Elapsed.TotalSeconds, + owner._revealProgress); + } + } +#else + private void Canvas_PaintSurface(object? sender, SKPaintSurfaceEventArgs e) + { + e.Surface.Canvas.Clear(SkiaSharp.SKColors.Transparent); + _renderer.Render( + e.Surface.Canvas, + e.Info.Width, + e.Info.Height, + (float)_clock.Elapsed.TotalSeconds, + _revealProgress); + } +#endif + + private void IntroductionBackground_Loaded(object sender, RoutedEventArgs e) + { + _renderer.SetTheme(ActualTheme == ElementTheme.Light); + _frameTimer.Start(); + } + + private void IntroductionBackground_Unloaded(object sender, RoutedEventArgs e) + { + _frameTimer.Stop(); + + // Never leave a pending reveal awaiter hanging if the control is torn down mid-animation + _revealTcs?.TrySetResult(); + } + + private void IntroductionBackground_ActualThemeChanged(FrameworkElement sender, object args) + { + _renderer.SetTheme(ActualTheme == ElementTheme.Light); + } + + /// + public new void Dispose() + { + _frameTimer.Stop(); + _frameTimer.Tick -= FrameTimer_Tick; + + Loaded -= IntroductionBackground_Loaded; + Unloaded -= IntroductionBackground_Unloaded; + ActualThemeChanged -= IntroductionBackground_ActualThemeChanged; + + _renderer.Dispose(); + } + } +} diff --git a/src/Platforms/SecureFolderFS.Uno/UserControls/Introduction/IntroductionBackgroundRenderer.cs b/src/Platforms/SecureFolderFS.Uno/UserControls/Introduction/IntroductionBackgroundRenderer.cs new file mode 100644 index 000000000..39b5d9421 --- /dev/null +++ b/src/Platforms/SecureFolderFS.Uno/UserControls/Introduction/IntroductionBackgroundRenderer.cs @@ -0,0 +1,237 @@ +using System; +using SkiaSharp; + +namespace SecureFolderFS.Uno.UserControls.Introduction +{ + /// + /// Paints the animated, grainy mesh-gradient used behind the introduction slides. + /// The color field is drawn at a low resolution and upscaled, which gives the soft, + /// blurred gradient look; a static noise tile on top provides the film-grain texture. + /// + internal sealed class IntroductionBackgroundRenderer : IDisposable + { + private const int FIELD_MAX_SIZE = 160; // the color field is rendered at most this large, then upscaled + private const int NOISE_TILE_SIZE = 256; + private const float GRAIN_SCALE = 1.6f; // enlarges the noise texels so the grain reads clearly + private const float REVEAL_BAND = 0.22f; // relative height of the soft edge of the reveal wipe + private const float REVEAL_LIFT = 0.25f; // relative distance the colors travel upward while revealing + + private SKBitmap? _noiseBitmap; + private SKShader? _grainShader; + private SKSurface? _fieldSurface; + private SKSizeI _fieldSize; + + private GradientBlob[] _blobs = []; + private SKColor _baseTop; + private SKColor _baseBottom; + private byte _grainAlpha; + + /// + /// Gets or sets whether the field upscale uses cubic resampling. Keep enabled on + /// GPU-composited hosts; disable on CPU-rasterized hosts where cubic is expensive. + /// + public bool HighQualitySampling { get; set; } = true; + + public IntroductionBackgroundRenderer() + { + SetTheme(light: false); + } + + public void SetTheme(bool light) + { + if (light) + { + _baseTop = new SKColor(0xDE, 0xEB, 0xF8); + _baseBottom = new SKColor(0xA6, 0xC4, 0xE2); + _grainAlpha = 0x18; + _blobs = + [ + new(new SKColor(0x2E, 0x8B, 0xEE, 0xDC), 0.80f, 2.0f, -18f, 4f, 0.25f, 0.32f, 0.22f, 0.18f, 0.33f, 0.21f, 0.0f), + new(new SKColor(0xFF, 0xFF, 0xFF, 0xE6), 0.50f, 2.3f, 24f, -6f, 0.62f, 0.12f, 0.26f, 0.16f, 0.27f, 0.36f, 1.9f), + new(new SKColor(0x4A, 0x6E, 0x96, 0xC8), 0.68f, 2.4f, 30f, 3f, 0.85f, 0.52f, 0.18f, 0.24f, 0.15f, 0.24f, 3.6f), + new(new SKColor(0x15, 0x5D, 0xAD, 0xA5), 0.72f, 1.6f, -40f, -5f, 0.30f, 0.88f, 0.22f, 0.14f, 0.21f, 0.30f, 2.6f), + new(new SKColor(0x58, 0x7C, 0xA4, 0xB4), 0.55f, 2.0f, 12f, 7f, 0.58f, 0.98f, 0.30f, 0.12f, 0.27f, 0.12f, 5.1f), + new(new SKColor(0x9E, 0xCB, 0xF7, 0xC8), 0.42f, 1.8f, 65f, -4f, 0.10f, 0.10f, 0.14f, 0.18f, 0.36f, 0.27f, 4.2f) + ]; + } + else + { + _baseTop = new SKColor(0x04, 0x10, 0x1F); + _baseBottom = new SKColor(0x0A, 0x21, 0x38); + _grainAlpha = 0x26; + _blobs = + [ + new(new SKColor(0x21, 0x96, 0xFF, 0xF0), 0.80f, 2.0f, -18f, 4f, 0.25f, 0.32f, 0.22f, 0.18f, 0.33f, 0.21f, 0.0f), + new(new SKColor(0xA8, 0xD4, 0xFF, 0x96), 0.50f, 2.3f, 24f, -6f, 0.62f, 0.12f, 0.26f, 0.16f, 0.27f, 0.36f, 1.9f), + new(new SKColor(0x00, 0x01, 0x04, 0xFA), 0.68f, 2.4f, 30f, 3f, 0.85f, 0.52f, 0.18f, 0.24f, 0.15f, 0.24f, 3.6f), + new(new SKColor(0x0D, 0x5C, 0xC0, 0xE0), 0.72f, 1.6f, -40f, -5f, 0.30f, 0.88f, 0.22f, 0.14f, 0.21f, 0.30f, 2.6f), + new(new SKColor(0x01, 0x04, 0x09, 0xDC), 0.55f, 2.0f, 12f, 7f, 0.58f, 0.98f, 0.30f, 0.12f, 0.27f, 0.12f, 5.1f), + new(new SKColor(0x4F, 0xAC, 0xFF, 0x78), 0.42f, 1.8f, 65f, -4f, 0.10f, 0.10f, 0.14f, 0.18f, 0.36f, 0.27f, 4.2f) + ]; + } + } + + /// + /// Draws one frame onto . Nothing is drawn while + /// is 0; a partially revealed frame keeps everything + /// above the wipe front fully transparent. + /// + public void Render(SKCanvas canvas, float width, float height, float time, float reveal) + { + if (width <= 0f || height <= 0f || reveal <= 0f) + return; + + var needsMask = reveal < 1f; + if (needsMask) + canvas.SaveLayer(); + + DrawColorField(canvas, width, height, time, reveal); + DrawGrain(canvas, width, height); + + if (needsMask) + { + // Erase everything above the reveal front, with a soft gradient edge + var band = height * REVEAL_BAND; + var frontTop = height - reveal * (height + band); + + using var maskPaint = new SKPaint(); + maskPaint.BlendMode = SKBlendMode.DstIn; + maskPaint.Shader = SKShader.CreateLinearGradient( + new SKPoint(0f, frontTop), + new SKPoint(0f, frontTop + band), + [SKColors.Transparent, SKColors.White], + null, + SKShaderTileMode.Clamp); + + canvas.DrawRect(new SKRect(0, 0, width, height), maskPaint); + canvas.Restore(); + } + } + + private void DrawColorField(SKCanvas canvas, float width, float height, float time, float reveal) + { + var scale = Math.Min(1f, FIELD_MAX_SIZE / Math.Max(width, height)); + var fieldSize = new SKSizeI( + Math.Max(16, (int)(width * scale)), + Math.Max(16, (int)(height * scale))); + + if (_fieldSurface is null || fieldSize != _fieldSize) + { + _fieldSurface?.Dispose(); + _fieldSurface = SKSurface.Create(new SKImageInfo(fieldSize.Width, fieldSize.Height)); + _fieldSize = fieldSize; + } + + var field = _fieldSurface.Canvas; + var fieldWidth = (float)fieldSize.Width; + var fieldHeight = (float)fieldSize.Height; + var maxDimension = Math.Max(fieldWidth, fieldHeight); + + // Base vertical gradient + using (var basePaint = new SKPaint()) + { + basePaint.Shader = SKShader.CreateLinearGradient( + new SKPoint(0f, 0f), + new SKPoint(0f, fieldHeight), + [_baseTop, _baseBottom], + null, + SKShaderTileMode.Clamp); + + field.DrawRect(new SKRect(0, 0, fieldWidth, fieldHeight), basePaint); + } + + // Elongated, slowly rotating gradient patches drifting along Lissajous paths + // form the sweeping bands of color. While revealing, they sit lower and rise + // into place with the wipe. + var lift = (1f - reveal) * REVEAL_LIFT; + foreach (var blob in _blobs) + { + var centerX = (blob.AnchorX + blob.AmplitudeX * MathF.Sin(blob.SpeedX * time + blob.Phase)) * fieldWidth; + var centerY = (blob.AnchorY + blob.AmplitudeY * MathF.Cos(blob.SpeedY * time + blob.Phase * 1.7f) + lift) * fieldHeight; + var radius = blob.Radius * maxDimension; + + using var blobPaint = new SKPaint(); + blobPaint.Shader = SKShader.CreateRadialGradient( + default, + radius, + [blob.Color, blob.Color.WithAlpha(0)], + null, + SKShaderTileMode.Clamp); + + field.Save(); + field.Translate(centerX, centerY); + field.RotateDegrees(blob.AngleDegrees + blob.RotationSpeed * time); + field.Scale(blob.Stretch, 1f); + field.DrawCircle(0f, 0f, radius, blobPaint); + field.Restore(); + } + + using var snapshot = _fieldSurface.Snapshot(); + var sampling = HighQualitySampling + ? new SKSamplingOptions(SKCubicResampler.Mitchell) + : new SKSamplingOptions(SKFilterMode.Linear); + + canvas.DrawImage(snapshot, new SKRect(0, 0, width, height), sampling); + } + + private void DrawGrain(SKCanvas canvas, float width, float height) + { + // The grain is intentionally static: a fixed noise layer overlaid + // on top of the moving colors, like film grain on footage + if (_grainShader is null) + { + _noiseBitmap = CreateNoiseBitmap(); + _grainShader = _noiseBitmap.ToShader( + SKShaderTileMode.Repeat, + SKShaderTileMode.Repeat, + SKMatrix.CreateScale(GRAIN_SCALE, GRAIN_SCALE)); + } + + using var grainPaint = new SKPaint(); + grainPaint.BlendMode = SKBlendMode.Overlay; + grainPaint.Color = SKColors.White.WithAlpha(_grainAlpha); + grainPaint.Shader = _grainShader; + + canvas.DrawRect(new SKRect(0, 0, width, height), grainPaint); + } + + private static unsafe SKBitmap CreateNoiseBitmap() + { + var bitmap = new SKBitmap(new SKImageInfo(NOISE_TILE_SIZE, NOISE_TILE_SIZE, SKColorType.Gray8, SKAlphaType.Opaque)); + var pixels = new Span((void*)bitmap.GetPixels(), bitmap.ByteCount); + Random.Shared.NextBytes(pixels); + + return bitmap; + } + + /// + public void Dispose() + { + _fieldSurface?.Dispose(); + _fieldSurface = null; + _grainShader?.Dispose(); + _grainShader = null; + _noiseBitmap?.Dispose(); + _noiseBitmap = null; + } + + /// + /// A single soft gradient patch of the color field, stretched into an ellipse and + /// slowly rotating. Coordinates, amplitudes, and the radius are relative to the field + /// size; speeds are in radians per second, rotation in degrees per second. + /// + private readonly record struct GradientBlob( + SKColor Color, + float Radius, + float Stretch, + float AngleDegrees, + float RotationSpeed, + float AnchorX, + float AnchorY, + float AmplitudeX, + float AmplitudeY, + float SpeedX, + float SpeedY, + float Phase); + } +} diff --git a/src/Platforms/SecureFolderFS.Uno/UserControls/Introduction/IntroductionControl.xaml b/src/Platforms/SecureFolderFS.Uno/UserControls/Introduction/IntroductionControl.xaml index 760aa28a7..8d4214389 100644 --- a/src/Platforms/SecureFolderFS.Uno/UserControls/Introduction/IntroductionControl.xaml +++ b/src/Platforms/SecureFolderFS.Uno/UserControls/Introduction/IntroductionControl.xaml @@ -1,4 +1,4 @@ - + mc:Ignorable="d"> - + + Duration="0:0:0.3"> + Duration="0:0:0.3"> + Duration="0:0:0.3"> @@ -58,7 +57,7 @@ @@ -67,7 +66,7 @@ @@ -78,76 +77,73 @@ - - - - - - - - - - - - - - - - - + + + x:Name="ContentGrid" + Opacity="0" + RenderTransformOrigin="0.5,0.5" + RowSpacing="16"> + + + + + + + + + + + + + + + + + - - - - - - - - + + + + + + + + - - - - - - - - - + + + + + + + + + - - + + - - + + - - + + - - + + + diff --git a/src/Platforms/SecureFolderFS.Uno/UserControls/Introduction/IntroductionControl.xaml.cs b/src/Platforms/SecureFolderFS.Uno/UserControls/Introduction/IntroductionControl.xaml.cs index 422b9ba49..a881f57c2 100644 --- a/src/Platforms/SecureFolderFS.Uno/UserControls/Introduction/IntroductionControl.xaml.cs +++ b/src/Platforms/SecureFolderFS.Uno/UserControls/Introduction/IntroductionControl.xaml.cs @@ -13,11 +13,8 @@ using SecureFolderFS.Shared.Extensions; using SecureFolderFS.Shared.Helpers; using SecureFolderFS.Shared.Models; -using SecureFolderFS.UI; -using SecureFolderFS.UI.Enums; using SecureFolderFS.UI.Utils; using SecureFolderFS.Uno.Extensions; -using SecureFolderFS.Uno.Helpers; using SecureFolderFS.Uno.UserControls.InterfaceRoot; // To learn more about WinUI, the WinUI project structure, @@ -63,12 +60,16 @@ public async Task ShowAsync() // Set the visibility of the overlay container _overlayContainer.Visibility = Visibility.Visible; - RootGrid.Opacity = 0; + RootGrid.Opacity = 1; + ContentGrid.Opacity = 0; + + // Sweep the animated background up from the bottom, covering the app behind it + await BackgroundControl.RevealAsync(); - // Play the show animation + // Then fade + scale the slides into view await ShowOverlayStoryboard.BeginAsync(); ShowOverlayStoryboard.Stop(); - RootGrid.Opacity = 1; + ContentGrid.Opacity = 1; // Wait for the overlay to be closed return await ViewModel.TaskCompletion.Task; @@ -102,6 +103,9 @@ public async Task HideAsync() _overlayContainer = null; } + // Dispose the background only once it can no longer be rendered + BackgroundControl.Dispose(); + ViewModel?.TaskCompletion.SetResult(Result.Success); } @@ -117,22 +121,8 @@ private async void ViewModel_PropertyChanged(object? sender, PropertyChangedEven } } - private async void BackgroundWebView_Loaded(object sender, RoutedEventArgs e) + private async void IntroductionControl_Loaded(object sender, RoutedEventArgs e) { - var htmlString = Constants.Introduction.BACKGROUND_WEBVIEW - .Replace("c_bg", UnoThemeHelper.Instance.ActualTheme switch - { - ThemeType.Light => "vec3(0.80, 0.86, 0.92)", - _ => "vec3(0.00, 0.08, 0.15)" - }) - .Replace("c_wave", UnoThemeHelper.Instance.ActualTheme switch - { - ThemeType.Light => "vec3(0.10, 0.42, 0.75)", - _ => "vec3(0.090, 0.569, 1.0)" - }); - - await BackgroundWebView.EnsureCoreWebView2Async(); - BackgroundWebView.NavigateToString(htmlString); await EncryptedFileSlide.InitAsync(); } From fe12a35e7dd21708dc133ce7bd0e2c55c70e58f1 Mon Sep 17 00:00:00 2001 From: d2dyno <53011783+d2dyno1@users.noreply.github.com> Date: Fri, 17 Jul 2026 13:57:18 +0200 Subject: [PATCH 2/4] Added drop shadow --- .../Introduction/IntroductionBackground.cs | 67 +++++++- .../IntroductionBackgroundRenderer.cs | 160 +++++++++++++++++- .../Introduction/IntroductionControl.xaml | 25 +-- .../Introduction/IntroductionControl.xaml.cs | 11 ++ 4 files changed, 232 insertions(+), 31 deletions(-) diff --git a/src/Platforms/SecureFolderFS.Uno/UserControls/Introduction/IntroductionBackground.cs b/src/Platforms/SecureFolderFS.Uno/UserControls/Introduction/IntroductionBackground.cs index 2bd9e5a7c..bd3a39c44 100644 --- a/src/Platforms/SecureFolderFS.Uno/UserControls/Introduction/IntroductionBackground.cs +++ b/src/Platforms/SecureFolderFS.Uno/UserControls/Introduction/IntroductionBackground.cs @@ -3,9 +3,9 @@ using System.Threading.Tasks; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; -#if HAS_UNO_SKIA using Windows.Foundation; using SkiaSharp; +#if HAS_UNO_SKIA using Uno.WinUI.Graphics2DSK; #else using SkiaSharp.Views.Windows; @@ -20,6 +20,7 @@ namespace SecureFolderFS.Uno.UserControls.Introduction public sealed partial class IntroductionBackground : UserControl, IDisposable { private const double REVEAL_DURATION_MS = 750d; + private const double SHADOW_FADE_MS = 350d; #if HAS_UNO_SKIA // SKCanvasElement draws directly in the app's composition pass (GPU-backed, @@ -37,9 +38,13 @@ public sealed partial class IntroductionBackground : UserControl, IDisposable private readonly DispatcherTimer _frameTimer; private readonly Stopwatch _clock = Stopwatch.StartNew(); + private bool _hasRendered; private float _revealProgress; + private float _shadowOpacity; private DateTime? _revealStart; + private DateTime? _shadowStart; private TaskCompletionSource? _revealTcs; + private Rect? _contentBounds; public IntroductionBackground() { @@ -80,29 +85,70 @@ public Task RevealAsync() return _revealTcs.Task; } + /// + /// Sets the bounds (relative to this control) of the elevated content that the + /// background draws a drop shadow beneath. + /// + public void SetContentBounds(Rect bounds) + { + _contentBounds = bounds; + } + private void FrameTimer_Tick(object? sender, object e) { if (_revealStart is { } revealStart) { - var progress = (float)((DateTime.UtcNow - revealStart).TotalMilliseconds / REVEAL_DURATION_MS); - if (progress >= 1f) + if (!_hasRendered) { - _revealProgress = 1f; - _revealStart = null; - _revealTcs?.TrySetResult(); + // The first composited frame can land well after RevealAsync was called + // (the freshly added overlay still needs to load and lay out); hold the + // clock at zero until then so the wipe visibly starts at the bottom + _revealStart = DateTime.UtcNow; } else - _revealProgress = 1f - MathF.Pow(1f - progress, 3f); // ease-out cubic + { + var progress = (float)((DateTime.UtcNow - revealStart).TotalMilliseconds / REVEAL_DURATION_MS); + if (progress >= 1f) + { + _revealProgress = 1f; + _revealStart = null; + _shadowStart = DateTime.UtcNow; + _revealTcs?.TrySetResult(); + } + else + _revealProgress = 1f - MathF.Pow(1f - progress, 3f); // ease-out cubic + } + } + + if (_shadowStart is { } shadowStart) + { + // Fade the content shadow in alongside the content's own entrance animation + var progress = (float)((DateTime.UtcNow - shadowStart).TotalMilliseconds / SHADOW_FADE_MS); + _shadowOpacity = progress >= 1f ? 1f : 1f - MathF.Pow(1f - progress, 3f); } _canvas.Invalidate(); } + private void PrepareFrame(float shadowScale) + { + _hasRendered = true; + _renderer.ShadowOpacity = _shadowOpacity; + _renderer.ShadowBounds = _contentBounds is { } bounds + ? new SKRect( + (float)(bounds.X * shadowScale), + (float)(bounds.Y * shadowScale), + (float)((bounds.X + bounds.Width) * shadowScale), + (float)((bounds.Y + bounds.Height) * shadowScale)) + : null; + } + #if HAS_UNO_SKIA private sealed class CompositionCanvas(IntroductionBackground owner) : SKCanvasElement { protected override void RenderOverride(SKCanvas canvas, Size area) { + owner.PrepareFrame(shadowScale: 1f); owner._renderer.Render( canvas, (float)area.Width, @@ -114,7 +160,11 @@ protected override void RenderOverride(SKCanvas canvas, Size area) #else private void Canvas_PaintSurface(object? sender, SKPaintSurfaceEventArgs e) { - e.Surface.Canvas.Clear(SkiaSharp.SKColors.Transparent); + // SKXamlCanvas works in physical pixels while element bounds are logical + var scale = ActualWidth > 0 ? (float)(e.Info.Width / ActualWidth) : 1f; + PrepareFrame(scale); + + e.Surface.Canvas.Clear(SKColors.Transparent); _renderer.Render( e.Surface.Canvas, e.Info.Width, @@ -133,6 +183,7 @@ private void IntroductionBackground_Loaded(object sender, RoutedEventArgs e) private void IntroductionBackground_Unloaded(object sender, RoutedEventArgs e) { _frameTimer.Stop(); + _hasRendered = false; // Never leave a pending reveal awaiter hanging if the control is torn down mid-animation _revealTcs?.TrySetResult(); diff --git a/src/Platforms/SecureFolderFS.Uno/UserControls/Introduction/IntroductionBackgroundRenderer.cs b/src/Platforms/SecureFolderFS.Uno/UserControls/Introduction/IntroductionBackgroundRenderer.cs index 39b5d9421..322c66bf5 100644 --- a/src/Platforms/SecureFolderFS.Uno/UserControls/Introduction/IntroductionBackgroundRenderer.cs +++ b/src/Platforms/SecureFolderFS.Uno/UserControls/Introduction/IntroductionBackgroundRenderer.cs @@ -14,7 +14,74 @@ internal sealed class IntroductionBackgroundRenderer : IDisposable private const int NOISE_TILE_SIZE = 256; private const float GRAIN_SCALE = 1.6f; // enlarges the noise texels so the grain reads clearly private const float REVEAL_BAND = 0.22f; // relative height of the soft edge of the reveal wipe - private const float REVEAL_LIFT = 0.25f; // relative distance the colors travel upward while revealing + private const float REVEAL_LIFT = 0.30f; // relative distance the colors travel upward while revealing + + /// + /// Domain-warped fractal noise: fbm distorted by two nested fbm passes folds the color + /// bands into the marbled, swirling shapes that plain gradient blobs cannot produce. + /// Runs on the tiny low-res field surface, so it is cheap even on CPU rasterizers. + /// + private const string FIELD_SKSL = + """ + uniform float2 uSize; + uniform float uTime; + uniform float uLift; + uniform float3 uBase; + uniform float3 uBright; + uniform float3 uIce; + uniform float3 uDark; + + float hash(float2 p) { + p = fract(p * float2(123.34, 456.21)); + p += dot(p, p + 45.32); + return fract(p.x * p.y); + } + + float vnoise(float2 p) { + float2 i = floor(p); + float2 f = fract(p); + float2 u = f * f * (3.0 - 2.0 * f); + float a = hash(i); + float b = hash(i + float2(1.0, 0.0)); + float c = hash(i + float2(0.0, 1.0)); + float d = hash(i + float2(1.0, 1.0)); + return mix(mix(a, b, u.x), mix(c, d, u.x), u.y); + } + + float fbm(float2 p) { + float v = 0.0; + float a = 0.55; + for (int i = 0; i < 3; i++) { + v += a * vnoise(p); + p = p * 2.03 + float2(17.0, 9.0); + a *= 0.45; + } + return v; + } + + half4 main(float2 frag) { + float2 uv = frag / uSize; + float2 p = float2(uv.x * uSize.x / uSize.y, uv.y + uLift) * 1.35; + + float t = uTime * 0.15; + float2 q = float2( + fbm(p + t * float2(0.9, 0.6)), + fbm(p + float2(5.2, 1.3) - t * float2(0.5, 0.8))); + float2 r = float2( + fbm(p + 2.4 * q + float2(1.7, 9.2) + t * float2(0.6, -0.4)), + fbm(p + 2.4 * q + float2(8.3, 2.8) + t * float2(-0.7, 0.5))); + float f = fbm(p + 2.6 * r); + + float3 col = mix(uBase, uBright, smoothstep(0.26, 0.76, f)); + col = mix(col, uIce, smoothstep(0.48, 0.85, q.y) * 0.9); + col = mix(col, uDark, smoothstep(0.40, 0.78, r.x)); + col = mix(col, uDark, 0.22 * uv.y); + + return half4(half3(col), 1.0); + } + """; + + private static readonly SKRuntimeEffect? FieldEffect = SKRuntimeEffect.CreateShader(FIELD_SKSL, out _); private SKBitmap? _noiseBitmap; private SKShader? _grainShader; @@ -25,6 +92,11 @@ internal sealed class IntroductionBackgroundRenderer : IDisposable private SKColor _baseTop; private SKColor _baseBottom; private byte _grainAlpha; + private bool _isLight; + private float[] _colorBase = []; + private float[] _colorBright = []; + private float[] _colorIce = []; + private float[] _colorDark = []; /// /// Gets or sets whether the field upscale uses cubic resampling. Keep enabled on @@ -32,6 +104,22 @@ internal sealed class IntroductionBackgroundRenderer : IDisposable /// public bool HighQualitySampling { get; set; } = true; + /// + /// Gets or sets the bounds (in canvas units) of the elevated content a drop shadow + /// is drawn beneath, or null for no shadow. + /// + public SKRect? ShadowBounds { get; set; } + + /// + /// Gets or sets the opacity (0..1) of the content drop shadow. + /// + public float ShadowOpacity { get; set; } + + /// + /// Gets or sets the corner radius of the content drop shadow. + /// + public float ShadowCornerRadius { get; set; } = 8f; + public IntroductionBackgroundRenderer() { SetTheme(light: false); @@ -39,8 +127,14 @@ public IntroductionBackgroundRenderer() public void SetTheme(bool light) { + _isLight = light; if (light) { + _colorBase = [0.66f, 0.78f, 0.90f]; // #A9C6E6 + _colorBright = [0.37f, 0.66f, 0.94f]; // #5FA8F0 + _colorIce = [1.00f, 1.00f, 1.00f]; // #FFFFFF + _colorDark = [0.30f, 0.43f, 0.58f]; // #4C6E93 + _baseTop = new SKColor(0xDE, 0xEB, 0xF8); _baseBottom = new SKColor(0xA6, 0xC4, 0xE2); _grainAlpha = 0x18; @@ -56,6 +150,11 @@ public void SetTheme(bool light) } else { + _colorBase = [0.07f, 0.23f, 0.40f]; // #123A66 + _colorBright = [0.18f, 0.61f, 1.00f]; // #2E9BFF + _colorIce = [0.66f, 0.84f, 1.00f]; // #A9D6FF + _colorDark = [0.004f, 0.02f, 0.047f]; // #01050C + _baseTop = new SKColor(0x04, 0x10, 0x1F); _baseBottom = new SKColor(0x0A, 0x21, 0x38); _grainAlpha = 0x26; @@ -87,6 +186,7 @@ public void Render(SKCanvas canvas, float width, float height, float time, float DrawColorField(canvas, width, height, time, reveal); DrawGrain(canvas, width, height); + DrawShadow(canvas); if (needsMask) { @@ -125,6 +225,43 @@ private void DrawColorField(SKCanvas canvas, float width, float height, float ti var field = _fieldSurface.Canvas; var fieldWidth = (float)fieldSize.Width; var fieldHeight = (float)fieldSize.Height; + var lift = (1f - reveal) * REVEAL_LIFT; + + if (FieldEffect is not null) + { + var uniforms = new SKRuntimeEffectUniforms(FieldEffect) + { + ["uSize"] = new[] { fieldWidth, fieldHeight }, + ["uTime"] = time, + ["uLift"] = lift, + ["uBase"] = _colorBase, + ["uBright"] = _colorBright, + ["uIce"] = _colorIce, + ["uDark"] = _colorDark + }; + + using var fieldShader = FieldEffect.ToShader(uniforms); + using var fieldPaint = new SKPaint(); + fieldPaint.Shader = fieldShader; + field.DrawRect(new SKRect(0, 0, fieldWidth, fieldHeight), fieldPaint); + } + else + DrawColorFieldFallback(field, fieldWidth, fieldHeight, time, lift); + + using var snapshot = _fieldSurface.Snapshot(); + var sampling = HighQualitySampling + ? new SKSamplingOptions(SKCubicResampler.Mitchell) + : new SKSamplingOptions(SKFilterMode.Linear); + + canvas.DrawImage(snapshot, new SKRect(0, 0, width, height), sampling); + } + + /// + /// Gradient-blob approximation of the color field, used if the SkSL runtime effect + /// is unavailable on the current Skia backend. + /// + private void DrawColorFieldFallback(SKCanvas field, float fieldWidth, float fieldHeight, float time, float lift) + { var maxDimension = Math.Max(fieldWidth, fieldHeight); // Base vertical gradient @@ -143,7 +280,6 @@ private void DrawColorField(SKCanvas canvas, float width, float height, float ti // Elongated, slowly rotating gradient patches drifting along Lissajous paths // form the sweeping bands of color. While revealing, they sit lower and rise // into place with the wipe. - var lift = (1f - reveal) * REVEAL_LIFT; foreach (var blob in _blobs) { var centerX = (blob.AnchorX + blob.AmplitudeX * MathF.Sin(blob.SpeedX * time + blob.Phase)) * fieldWidth; @@ -165,13 +301,23 @@ private void DrawColorField(SKCanvas canvas, float width, float height, float ti field.DrawCircle(0f, 0f, radius, blobPaint); field.Restore(); } + } - using var snapshot = _fieldSurface.Snapshot(); - var sampling = HighQualitySampling - ? new SKSamplingOptions(SKCubicResampler.Mitchell) - : new SKSamplingOptions(SKFilterMode.Linear); + private void DrawShadow(SKCanvas canvas) + { + if (ShadowBounds is not { } bounds || ShadowOpacity <= 0f) + return; - canvas.DrawImage(snapshot, new SKRect(0, 0, width, height), sampling); + var baseAlpha = _isLight ? 0x55 : 0x8C; + using var shadowPaint = new SKPaint(); + shadowPaint.IsAntialias = true; + shadowPaint.Color = new SKColor(0, 0, 0, (byte)(baseAlpha * Math.Clamp(ShadowOpacity, 0f, 1f))); + shadowPaint.MaskFilter = SKMaskFilter.CreateBlur(SKBlurStyle.Normal, 20f); + + var rect = bounds; + rect.Offset(0f, 10f); + rect.Inflate(2f, 2f); + canvas.DrawRoundRect(rect, ShadowCornerRadius, ShadowCornerRadius, shadowPaint); } private void DrawGrain(SKCanvas canvas, float width, float height) diff --git a/src/Platforms/SecureFolderFS.Uno/UserControls/Introduction/IntroductionControl.xaml b/src/Platforms/SecureFolderFS.Uno/UserControls/Introduction/IntroductionControl.xaml index 8d4214389..b503a49c4 100644 --- a/src/Platforms/SecureFolderFS.Uno/UserControls/Introduction/IntroductionControl.xaml +++ b/src/Platforms/SecureFolderFS.Uno/UserControls/Introduction/IntroductionControl.xaml @@ -88,36 +88,29 @@ + - - - + + + - - - + + + + Grid.Column="1" + SizeChanged="ShadowTarget_SizeChanged" /> - - - - - - - - - diff --git a/src/Platforms/SecureFolderFS.Uno/UserControls/Introduction/IntroductionControl.xaml.cs b/src/Platforms/SecureFolderFS.Uno/UserControls/Introduction/IntroductionControl.xaml.cs index a881f57c2..1f7081c29 100644 --- a/src/Platforms/SecureFolderFS.Uno/UserControls/Introduction/IntroductionControl.xaml.cs +++ b/src/Platforms/SecureFolderFS.Uno/UserControls/Introduction/IntroductionControl.xaml.cs @@ -126,6 +126,17 @@ private async void IntroductionControl_Loaded(object sender, RoutedEventArgs e) await EncryptedFileSlide.InitAsync(); } + private void ShadowTarget_SizeChanged(object sender, SizeChangedEventArgs e) + { + if (ShadowTarget.ActualWidth <= 0d || ShadowTarget.ActualHeight <= 0d) + return; + + // Tell the background where the elevated content sits so it can draw a drop shadow beneath it + var transform = ShadowTarget.TransformToVisual(BackgroundControl); + var bounds = transform.TransformBounds(new Windows.Foundation.Rect(0d, 0d, ShadowTarget.ActualWidth, ShadowTarget.ActualHeight)); + BackgroundControl.SetContentBounds(bounds); + } + private void IntroductionControl_KeyDown(object sender, KeyRoutedEventArgs e) { if (ViewModel is null) From 2259aca1d32eae6aa8dc4bb50765e15d9c1a6b2c Mon Sep 17 00:00:00 2001 From: d2dyno <53011783+d2dyno1@users.noreply.github.com> Date: Fri, 17 Jul 2026 14:28:15 +0200 Subject: [PATCH 3/4] Removed the dashboard image --- .../SecureFolderFS.UI.csproj | 1 - .../ResourceDictionaries/ImageResources.xaml | 8 -- .../Introduction/AuthenticationSlide.xaml | 32 ++--- .../Introduction/AuthenticationSlide.xaml.cs | 51 ++++++++ .../Introduction/DashboardSlide.xaml | 78 +++++++++++++ .../Introduction/DashboardSlide.xaml.cs | 110 ++++++++++++++++++ .../Introduction/FileSystemSlide.xaml | 21 +++- .../Introduction/FileSystemSlide.xaml.cs | 30 +---- .../Introduction/IntroductionControl.xaml | 10 +- .../Overlays/IntroductionOverlayViewModel.cs | 3 + 10 files changed, 274 insertions(+), 70 deletions(-) create mode 100644 src/Platforms/SecureFolderFS.Uno/UserControls/Introduction/DashboardSlide.xaml create mode 100644 src/Platforms/SecureFolderFS.Uno/UserControls/Introduction/DashboardSlide.xaml.cs diff --git a/src/Platforms/SecureFolderFS.UI/SecureFolderFS.UI.csproj b/src/Platforms/SecureFolderFS.UI/SecureFolderFS.UI.csproj index d20e56ab8..d9504ad99 100644 --- a/src/Platforms/SecureFolderFS.UI/SecureFolderFS.UI.csproj +++ b/src/Platforms/SecureFolderFS.UI/SecureFolderFS.UI.csproj @@ -32,7 +32,6 @@ - diff --git a/src/Platforms/SecureFolderFS.Uno/ResourceDictionaries/ImageResources.xaml b/src/Platforms/SecureFolderFS.Uno/ResourceDictionaries/ImageResources.xaml index eb27d81c4..957329cb9 100644 --- a/src/Platforms/SecureFolderFS.Uno/ResourceDictionaries/ImageResources.xaml +++ b/src/Platforms/SecureFolderFS.Uno/ResourceDictionaries/ImageResources.xaml @@ -20,10 +20,6 @@ - @@ -39,10 +35,6 @@ - diff --git a/src/Platforms/SecureFolderFS.Uno/UserControls/Introduction/AuthenticationSlide.xaml b/src/Platforms/SecureFolderFS.Uno/UserControls/Introduction/AuthenticationSlide.xaml index 33ff5f964..1546f2afb 100644 --- a/src/Platforms/SecureFolderFS.Uno/UserControls/Introduction/AuthenticationSlide.xaml +++ b/src/Platforms/SecureFolderFS.Uno/UserControls/Introduction/AuthenticationSlide.xaml @@ -13,39 +13,21 @@ - + - + - - - - - - - - - - + StrokeThickness="1.5" /> - + diff --git a/src/Platforms/SecureFolderFS.Uno/UserControls/Introduction/AuthenticationSlide.xaml.cs b/src/Platforms/SecureFolderFS.Uno/UserControls/Introduction/AuthenticationSlide.xaml.cs index 5d7fcd602..d229bf9ed 100644 --- a/src/Platforms/SecureFolderFS.Uno/UserControls/Introduction/AuthenticationSlide.xaml.cs +++ b/src/Platforms/SecureFolderFS.Uno/UserControls/Introduction/AuthenticationSlide.xaml.cs @@ -1,5 +1,6 @@ using System; using System.Threading.Tasks; +using Windows.Foundation; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; using Microsoft.UI.Xaml.Media; @@ -44,6 +45,56 @@ private static void ResetIcon(Border icon) } } + private void IconsGrid_SizeChanged(object sender, SizeChangedEventArgs e) + { + RebuildPathGeometry(); + } + + /// + /// Connects the centers of the four icon circles with smooth S-curves. Built from the + /// actual layout positions so the connection points stay aligned regardless of sizing. + /// + private void RebuildPathGeometry() + { + if (DiagramGrid.ActualWidth <= 0d || DiagramGrid.ActualHeight <= 0d) + return; + + Point[] centers = + [ + CenterOf(PasswordBorder), + CenterOf(DeviceLinkBorder), + CenterOf(YubiKeyBorder), + CenterOf(KeyFileBorder) + ]; + + var figure = new PathFigure { StartPoint = centers[0], IsClosed = false, IsFilled = false }; + for (var i = 1; i < centers.Length; i++) + { + // Vertical tangents at both ends make the dashed line leave and enter each circle straight down + var previous = centers[i - 1]; + var current = centers[i]; + var middleY = (previous.Y + current.Y) / 2d; + + figure.Segments.Add(new BezierSegment + { + Point1 = new Point(previous.X, middleY), + Point2 = new Point(current.X, middleY), + Point3 = current + }); + } + + var geometry = new PathGeometry(); + geometry.Figures.Add(figure); + AuthenticationPath.Data = geometry; + + Point CenterOf(FrameworkElement element) + { + return element + .TransformToVisual(DiagramGrid) + .TransformPoint(new Point(element.ActualWidth / 2d, element.ActualHeight / 2d)); + } + } + /// /// Animates the four icons (tiles) to pop into view from top to bottom (staggered). /// Once all icons have finished animating, the S-curve path fades in to 25% opacity. diff --git a/src/Platforms/SecureFolderFS.Uno/UserControls/Introduction/DashboardSlide.xaml b/src/Platforms/SecureFolderFS.Uno/UserControls/Introduction/DashboardSlide.xaml new file mode 100644 index 000000000..84dcf9173 --- /dev/null +++ b/src/Platforms/SecureFolderFS.Uno/UserControls/Introduction/DashboardSlide.xaml @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Platforms/SecureFolderFS.Uno/UserControls/Introduction/DashboardSlide.xaml.cs b/src/Platforms/SecureFolderFS.Uno/UserControls/Introduction/DashboardSlide.xaml.cs new file mode 100644 index 000000000..d83cff432 --- /dev/null +++ b/src/Platforms/SecureFolderFS.Uno/UserControls/Introduction/DashboardSlide.xaml.cs @@ -0,0 +1,110 @@ +using System; +using Windows.Foundation; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Media; + +// The User Control item template is documented at https://go.microsoft.com/fwlink/?LinkId=234236 + +namespace SecureFolderFS.Uno.UserControls.Introduction +{ + /// + /// The dashboard feature slide, showing a live mock of the vault dashboard: + /// a health status card and a continuously flowing read-speed graph. + /// + public sealed partial class DashboardSlide : UserControl + { + private const int SAMPLE_COUNT = 48; + private const int FRAME_INTERVAL_MS = 100; + private const double MIN_SPEED = 40d; + private const double MAX_SPEED = 280d; + + private readonly DispatcherTimer _frameTimer; + private double _time; + private int _ticksSinceLabelUpdate; + + public DashboardSlide() + { + InitializeComponent(); + + _frameTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(FRAME_INTERVAL_MS) }; + _frameTimer.Tick += FrameTimer_Tick; + + Loaded += DashboardSlide_Loaded; + Unloaded += DashboardSlide_Unloaded; + } + + /// + /// Produces a smooth pseudo-random sine wave. + /// + private static double SpeedAt(double time) + { + var wave = + Math.Sin(time * 0.9) * 0.45 + + Math.Sin(time * 2.3 + 1.3) * 0.3 + + Math.Sin(time * 4.1 + 0.7) * 0.15; + + var normalized = (wave + 0.9) / 1.8; // roughly 0..1 + return MIN_SPEED + normalized * (MAX_SPEED - MIN_SPEED); + } + + private void FrameTimer_Tick(object? sender, object e) + { + _time += FRAME_INTERVAL_MS / 1000d; + RebuildChart(); + + // Refresh the label less often so the number stays readable + if (++_ticksSinceLabelUpdate >= 5) + { + _ticksSinceLabelUpdate = 0; + SpeedText.Text = $"{(int)SpeedAt(_time)}mb/s"; + } + } + + private void RebuildChart() + { + var width = ChartHost.ActualWidth; + var height = ChartHost.ActualHeight; + if (width <= 0d || height <= 0d) + return; + + var linePoints = new PointCollection(); + var areaPoints = new PointCollection(); + + for (var i = 0; i < SAMPLE_COUNT; i++) + { + // The window ends at the current time, so the curve scrolls right to left + var sampleTime = _time - (SAMPLE_COUNT - 1 - i) * 0.18; + var normalized = (SpeedAt(sampleTime) - MIN_SPEED) / (MAX_SPEED - MIN_SPEED); + + var point = new Point( + i / (double)(SAMPLE_COUNT - 1) * width, + height - normalized * (height - 4d) - 2d); + + linePoints.Add(point); + areaPoints.Add(point); + } + + areaPoints.Add(new Point(width, height)); + areaPoints.Add(new Point(0, height)); + + SpeedPolyline.Points = linePoints; + AreaPolygon.Points = areaPoints; + } + + private void ChartHost_SizeChanged(object sender, SizeChangedEventArgs e) + { + RebuildChart(); + } + + private void DashboardSlide_Loaded(object sender, RoutedEventArgs e) + { + _frameTimer.Start(); + } + + private void DashboardSlide_Unloaded(object sender, RoutedEventArgs e) + { + _frameTimer.Stop(); + } + } +} diff --git a/src/Platforms/SecureFolderFS.Uno/UserControls/Introduction/FileSystemSlide.xaml b/src/Platforms/SecureFolderFS.Uno/UserControls/Introduction/FileSystemSlide.xaml index 5f121b4e2..08a908ed3 100644 --- a/src/Platforms/SecureFolderFS.Uno/UserControls/Introduction/FileSystemSlide.xaml +++ b/src/Platforms/SecureFolderFS.Uno/UserControls/Introduction/FileSystemSlide.xaml @@ -28,6 +28,14 @@ IconSource="{x:Bind Icon, Mode=OneWay, Converter={StaticResource IconSourceConverter}}" /> + + + @@ -72,6 +80,14 @@ + + + @@ -193,9 +209,10 @@ + SelectionMode="None"> diff --git a/src/Platforms/SecureFolderFS.Uno/UserControls/Introduction/FileSystemSlide.xaml.cs b/src/Platforms/SecureFolderFS.Uno/UserControls/Introduction/FileSystemSlide.xaml.cs index a21f2fef3..197c1a8ee 100644 --- a/src/Platforms/SecureFolderFS.Uno/UserControls/Introduction/FileSystemSlide.xaml.cs +++ b/src/Platforms/SecureFolderFS.Uno/UserControls/Introduction/FileSystemSlide.xaml.cs @@ -27,38 +27,18 @@ public FileSystemSlide() InitializeComponent(); } - private void FileSystems_SelectionChanged(object sender, SelectionChangedEventArgs e) + private void FileSystems_ItemClick(object sender, ItemClickEventArgs e) { if (OverlayViewModel is null) return; - if (sender is not ListView listView) + // Not installed yet - the Install button inside the item handles that flow + if (e.ClickedItem is ItemInstallationViewModel { IsInstalled: false }) return; - foreach (var item in OverlayViewModel.FileSystems) - item.IsSelected = false; - - var selectedItem = e.AddedItems.FirstOrDefault(); - if (selectedItem is ItemInstallationViewModel installation) - { - if (installation.IsInstalled) - { - installation.IsSelected = true; - OverlayViewModel.SelectedFileSystem = installation; - } - else - { - var oldSelected = e.RemovedItems.FirstOrDefault()?.TryCast(); - oldSelected?.IsSelected = true; - OverlayViewModel.SelectedFileSystem = oldSelected; - listView.SelectedItem = oldSelected; - } - } - else if (selectedItem is PickerOptionViewModel itemViewModel) - { - itemViewModel.IsSelected = true; + // The view model synchronizes IsSelected across all items, which drives the selection visuals + if (e.ClickedItem is PickerOptionViewModel itemViewModel) OverlayViewModel.SelectedFileSystem = itemViewModel; - } } public IntroductionOverlayViewModel? OverlayViewModel diff --git a/src/Platforms/SecureFolderFS.Uno/UserControls/Introduction/IntroductionControl.xaml b/src/Platforms/SecureFolderFS.Uno/UserControls/Introduction/IntroductionControl.xaml index b503a49c4..2ddf921cb 100644 --- a/src/Platforms/SecureFolderFS.Uno/UserControls/Introduction/IntroductionControl.xaml +++ b/src/Platforms/SecureFolderFS.Uno/UserControls/Introduction/IntroductionControl.xaml @@ -115,15 +115,7 @@ - - - - - + diff --git a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Overlays/IntroductionOverlayViewModel.cs b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Overlays/IntroductionOverlayViewModel.cs index f1881c712..de98990dc 100644 --- a/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Overlays/IntroductionOverlayViewModel.cs +++ b/src/Sdk/SecureFolderFS.Sdk/ViewModels/Views/Overlays/IntroductionOverlayViewModel.cs @@ -93,6 +93,9 @@ partial void OnSelectedFileSystemChanged(PickerOptionViewModel? value) if (value is null) return; + foreach (var item in FileSystems) + item.IsSelected = ReferenceEquals(item, value); + SettingsService.UserSettings.PreferredFileSystemId = value.Id; _ = SettingsService.TrySaveAsync(); } From 38b2c9163e318da00477e00e0dc692b38785fe97 Mon Sep 17 00:00:00 2001 From: d2dyno <53011783+d2dyno1@users.noreply.github.com> Date: Fri, 17 Jul 2026 15:05:03 +0200 Subject: [PATCH 4/4] Added FuseInstallationViewModel --- .../SkiaVaultFileSystemService.cs | 27 ++- .../ViewModels/FuseInstallationViewModel.cs | 146 +++++++++++++++ .../ViewModels/DokanyInstallationViewModel.cs | 126 ++----------- .../ViewModels/MsiInstallationViewModel.cs | 175 ++++++++++++++++++ .../ViewModels/WinFspInstallationViewModel.cs | 125 ++----------- .../Introduction/FileSystemSlide.xaml | 30 ++- .../Introduction/FileSystemSlide.xaml.cs | 60 +++++- 7 files changed, 442 insertions(+), 247 deletions(-) create mode 100644 src/Platforms/SecureFolderFS.Uno/Platforms/Desktop/ViewModels/FuseInstallationViewModel.cs create mode 100644 src/Platforms/SecureFolderFS.Uno/Platforms/Windows/ViewModels/MsiInstallationViewModel.cs diff --git a/src/Platforms/SecureFolderFS.Uno/Platforms/Desktop/ServiceImplementation/SkiaVaultFileSystemService.cs b/src/Platforms/SecureFolderFS.Uno/Platforms/Desktop/ServiceImplementation/SkiaVaultFileSystemService.cs index 603ecc4d2..35ed9d036 100644 --- a/src/Platforms/SecureFolderFS.Uno/Platforms/Desktop/ServiceImplementation/SkiaVaultFileSystemService.cs +++ b/src/Platforms/SecureFolderFS.Uno/Platforms/Desktop/ServiceImplementation/SkiaVaultFileSystemService.cs @@ -5,6 +5,7 @@ using SecureFolderFS.Sdk.Enums; using SecureFolderFS.Sdk.Models; using SecureFolderFS.Sdk.Services; +using SecureFolderFS.Sdk.ViewModels.Controls.Components; using SecureFolderFS.Sdk.ViewModels.Views.Wizard; using SecureFolderFS.Sdk.ViewModels.Views.Wizard.DataSources; using SecureFolderFS.Shared; @@ -12,6 +13,11 @@ using SecureFolderFS.UI.ServiceImplementation; using static SecureFolderFS.Sdk.Constants.DataSources; +#if !__UNO_SKIA_MACOS__ +using System; +using SecureFolderFS.Uno.Platforms.Desktop.ViewModels; +#endif + namespace SecureFolderFS.Uno.Platforms.Desktop.ServiceImplementation { /// @@ -24,8 +30,27 @@ public override async IAsyncEnumerable GetFileSystemsAsync([Enu yield return new SkiaWebDavFileSystem(); #if !__UNO_SKIA_MACOS__ - yield return new FuseFileSystem(); + // Inside a Flatpak sandbox the FUSE userspace may appear available, but mounts + // are confined to the sandbox's mount namespace and invisible to the host + if (!FuseInstallationViewModel.IsSandboxed) + yield return new FuseFileSystem(); +#endif + } + + /// + public override async IAsyncEnumerable GetFileSystemInstallationsAsync([EnumeratorCancellation] CancellationToken cancellationToken = default) + { +#if !__UNO_SKIA_MACOS__ + // Host packages cannot be installed from inside an application sandbox (e.g., Flatpak) + if (OperatingSystem.IsLinux() && !FuseInstallationViewModel.IsSandboxed) + { + var fuse = new FuseInstallationViewModel(); + await fuse.InitAsync(cancellationToken); + yield return fuse; + } #endif + await Task.CompletedTask; + yield break; } /// diff --git a/src/Platforms/SecureFolderFS.Uno/Platforms/Desktop/ViewModels/FuseInstallationViewModel.cs b/src/Platforms/SecureFolderFS.Uno/Platforms/Desktop/ViewModels/FuseInstallationViewModel.cs new file mode 100644 index 000000000..dc48dc038 --- /dev/null +++ b/src/Platforms/SecureFolderFS.Uno/Platforms/Desktop/ViewModels/FuseInstallationViewModel.cs @@ -0,0 +1,146 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using SecureFolderFS.Sdk.Services; +using SecureFolderFS.Sdk.ViewModels.Controls.Components; +using SecureFolderFS.Shared; +using SecureFolderFS.Shared.Models; +using Tmds.Fuse; + +namespace SecureFolderFS.Uno.Platforms.Desktop.ViewModels +{ + /// + /// Installs the FUSE userspace components (libfuse3 and fusermount3) through the + /// distribution's native package manager, elevated with a PolicyKit (pkexec) prompt. + /// The kernel half of FUSE ships with every mainstream distribution, so the 'fuse3' + /// package is all that is needed to make available. + /// + public sealed partial class FuseInstallationViewModel() : ItemInstallationViewModel(Core.FUSE.Constants.FileSystem.FS_ID, Core.FUSE.Constants.FileSystem.FS_NAME) + { + private const int PKEXEC_DIALOG_DISMISSED = 126; // The user closed the authentication dialog + private const int PKEXEC_NOT_AUTHORIZED = 127; + + // Package manager binaries and the arguments that install libfuse3 + fusermount3 + private static readonly (string FileName, string Arguments)[] KnownPackageManagers = + [ + ("apt-get", "install -y fuse3"), + ("dnf", "install -y fuse3"), + ("yum", "install -y fuse3"), + ("pacman", "-S --needed --noconfirm fuse3"), + ("zypper", "--non-interactive install fuse3") + ]; + + /// + /// Gets whether the app runs inside a Flatpak sandbox, where host packages cannot + /// be installed and sandbox-local FUSE mounts would not be visible to the host. + /// + public static bool IsSandboxed { get; } = File.Exists("/.flatpak-info"); + + /// + public override async Task InitAsync(CancellationToken cancellationToken = default) + { + var mediaService = DI.Service(); + Icon = await mediaService.GetImageFromResourceAsync(Id, cancellationToken); + } + + /// + protected override async Task InstallAsync(CancellationToken cancellationToken) + { + try + { + IsProgressing = true; + IsIndeterminate = true; // Package managers don't expose machine-readable progress + + if (FindProgram("pkexec") is null) + throw new InvalidOperationException("PolicyKit (pkexec) was not found. Install the 'fuse3' package manually with your package manager."); + + var (packageManager, arguments) = FindInstallCommand() + ?? throw new InvalidOperationException("No supported package manager was found. Install the 'fuse3' package manually."); + + var exitCode = await RunElevatedAsync(packageManager, arguments, cancellationToken); + switch (exitCode) + { + case 0: + break; + + case PKEXEC_DIALOG_DISMISSED: + return; // Dismissing the authentication dialog is deliberate - nothing to report + + case PKEXEC_NOT_AUTHORIZED: + throw new UnauthorizedAccessException("Authorization to install the 'fuse3' package was not granted."); + + default: + throw new InvalidOperationException($"The 'fuse3' package installation exited with code {exitCode}."); + } + + // Confirm that libfuse3 and fusermount3 are actually usable now + if (!Fuse.CheckDependencies()) + throw new InvalidOperationException("FUSE is still unavailable after the installation."); + + IsInstalled = true; + } + catch (OperationCanceledException) + { + // Cancellation, nothing to report + } + catch (Exception ex) + { + Report(Result.Failure(ex)); + } + finally + { + IsProgressing = false; + IsIndeterminate = false; + } + } + + private static (string FileName, string Arguments)? FindInstallCommand() + { + foreach (var (fileName, arguments) in KnownPackageManagers) + { + // pkexec resolves programs with its own restricted PATH, so pass the absolute path + if (FindProgram(fileName) is { } fullPath) + return (fullPath, arguments); + } + + return null; + } + + private static string? FindProgram(string fileName) + { + var pathDirectories = (Environment.GetEnvironmentVariable("PATH") ?? string.Empty) + .Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries); + + // Also probe the standard locations in case the app was launched with a minimal PATH + string[] fallbackDirectories = ["/usr/bin", "/usr/sbin", "/bin", "/sbin"]; + + foreach (var directory in (string[])[.. pathDirectories, .. fallbackDirectories]) + { + var candidate = Path.Combine(directory, fileName); + if (File.Exists(candidate)) + return candidate; + } + + return null; + } + + private static async Task RunElevatedAsync(string fileName, string arguments, CancellationToken cancellationToken) + { + var processInfo = new ProcessStartInfo + { + FileName = "pkexec", + Arguments = $"{fileName} {arguments}", + UseShellExecute = false, + CreateNoWindow = true + }; + + using var process = Process.Start(processInfo) + ?? throw new InvalidOperationException("Failed to start the elevated installation process."); + + await process.WaitForExitAsync(cancellationToken); + return process.ExitCode; + } + } +} diff --git a/src/Platforms/SecureFolderFS.Uno/Platforms/Windows/ViewModels/DokanyInstallationViewModel.cs b/src/Platforms/SecureFolderFS.Uno/Platforms/Windows/ViewModels/DokanyInstallationViewModel.cs index 851ff9564..a6ae678ec 100644 --- a/src/Platforms/SecureFolderFS.Uno/Platforms/Windows/ViewModels/DokanyInstallationViewModel.cs +++ b/src/Platforms/SecureFolderFS.Uno/Platforms/Windows/ViewModels/DokanyInstallationViewModel.cs @@ -1,72 +1,25 @@ using System; -using System.Diagnostics; -using System.IO; -using System.Net.Http; +using System.Collections.Generic; using System.Runtime.InteropServices; -using System.Threading; -using System.Threading.Tasks; using Octokit; -using OwlCore.Storage; -using SecureFolderFS.Sdk.Services; -using SecureFolderFS.Sdk.ViewModels.Controls.Components; -using SecureFolderFS.Shared; -using SecureFolderFS.Shared.Models; -using SecureFolderFS.Storage.SystemStorageEx; -using SecureFolderFS.UI; namespace SecureFolderFS.Uno.Platforms.Windows.ViewModels { - /// - public sealed partial class DokanyInstallationViewModel() : ItemInstallationViewModel(Core.Dokany.Constants.FileSystem.FS_ID, Core.Dokany.Constants.FileSystem.FS_NAME) + /// + public sealed partial class DokanyInstallationViewModel() : MsiInstallationViewModel(Core.Dokany.Constants.FileSystem.FS_ID, Core.Dokany.Constants.FileSystem.FS_NAME) { - private const string OWNER = "dokan-dev"; - private const string REPO = "dokany"; - /// - public override event EventHandler? StateChanged; + protected override string RepositoryOwner { get; } = "dokan-dev"; /// - public override async Task InitAsync(CancellationToken cancellationToken = default) - { - var mediaService = DI.Service(); - Icon = await mediaService.GetImageFromResourceAsync(Id, cancellationToken); - } + protected override string RepositoryName { get; } = "dokany"; /// - protected override async Task InstallAsync(CancellationToken cancellationToken) - { - try - { - IsIndeterminate = true; - - var (downloadUrl, fileName) = await GetInstallerAssetAsync(cancellationToken); - var installerFolder = new SystemFolderEx(Path.GetTempPath()); - - IsIndeterminate = false; - var downloadedFile = await DownloadFileAsync(downloadUrl, installerFolder, cancellationToken); - - IsIndeterminate = true; - await RunInstallerSilentlyAsync(downloadedFile, cancellationToken); - - // Delete installed file - await installerFolder.DeleteAsync(downloadedFile, cancellationToken); - IsInstalled = true; - } - catch (Exception ex) - { - Report(Result.Failure(ex)); - } - finally - { - IsProgressing = false; - IsIndeterminate = false; - } - } + protected override string VersionTag { get; } = Core.Dokany.Constants.FileSystem.VERSION_TAG; - private static async Task<(string downloadUrl, string fileName)> GetInstallerAssetAsync(CancellationToken cancellationToken) + /// + protected override ReleaseAsset? SelectInstallerAsset(IReadOnlyList assets) { - var github = new GitHubClient(new ProductHeaderValue(Constants.GitHub.REPOSITORY_OWNER)); - var release = await github.Repository.Release.Get(OWNER, REPO, Core.Dokany.Constants.FileSystem.VERSION_TAG).WaitAsync(cancellationToken); var archTag = RuntimeInformation.ProcessArchitecture switch { Architecture.X64 => "x64", @@ -75,69 +28,14 @@ protected override async Task InstallAsync(CancellationToken cancellationToken) _ => throw new PlatformNotSupportedException($"Unsupported architecture: {RuntimeInformation.ProcessArchitecture}") }; - ReleaseAsset? asset = null; - foreach (var item in release.Assets) + foreach (var item in assets) { var name = item.Name.ToLowerInvariant(); - if (!name.EndsWith(".msi") || !name.Contains(archTag)) - continue; - - asset = item; - break; + if (name.EndsWith(".msi") && name.Contains(archTag)) + return item; } - return asset is not null - ? (asset.BrowserDownloadUrl, asset.Name) - : throw new InvalidOperationException($"No MSI installer found for architecture '{archTag}' in the Dokany release."); - } - - private async Task DownloadFileAsync(string url, IModifiableFolder installerFolder, CancellationToken cancellationToken) - { - using var httpClient = new HttpClient(); - using var response = await httpClient.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, cancellationToken); - response.EnsureSuccessStatusCode(); - - var totalBytes = response.Content.Headers.ContentLength ?? -1L; - await using var contentStream = await response.Content.ReadAsStreamAsync(cancellationToken); - - var downloadedFile = await installerFolder.CreateFileAsync($"{nameof(SecureFolderFS)}_{Path.GetFileName(url)}", true, cancellationToken); - await using var downloadedFileStream = await downloadedFile.OpenReadWriteAsync(cancellationToken); - - var buffer = new byte[4096]; - var downloadedBytes = 0L; - int bytesRead; - - while ((bytesRead = await contentStream.ReadAsync(buffer, cancellationToken)) > 0) - { - await downloadedFileStream.WriteAsync(buffer.AsMemory(0, bytesRead), cancellationToken); - downloadedBytes += bytesRead; - - if (totalBytes > 0) - Report(downloadedBytes / (double)totalBytes * 100.0); - else - IsIndeterminate = true; // Content-Length unavailable, can't track progress - } - - return downloadedFile; - } - - private static async Task RunInstallerSilentlyAsync(IFile downloadFile, CancellationToken cancellationToken) - { - var processInfo = new ProcessStartInfo - { - FileName = "msiexec.exe", - Arguments = $"/i \"{downloadFile.Id}\" /quiet /norestart", - UseShellExecute = true, - Verb = "runas", - CreateNoWindow = true - }; - - using var process = Process.Start(processInfo) - ?? throw new InvalidOperationException("Failed to start the Dokany installer process."); - - await process.WaitForExitAsync(cancellationToken); - if (process.ExitCode is not (0 or 3010)) - throw new InvalidOperationException($"Dokany installer exited with code {process.ExitCode}."); + return null; } } } diff --git a/src/Platforms/SecureFolderFS.Uno/Platforms/Windows/ViewModels/MsiInstallationViewModel.cs b/src/Platforms/SecureFolderFS.Uno/Platforms/Windows/ViewModels/MsiInstallationViewModel.cs new file mode 100644 index 000000000..2d9b9a798 --- /dev/null +++ b/src/Platforms/SecureFolderFS.Uno/Platforms/Windows/ViewModels/MsiInstallationViewModel.cs @@ -0,0 +1,175 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Diagnostics; +using System.IO; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Octokit; +using OwlCore.Storage; +using SecureFolderFS.Sdk.Services; +using SecureFolderFS.Sdk.ViewModels.Controls.Components; +using SecureFolderFS.Shared; +using SecureFolderFS.Shared.Models; +using SecureFolderFS.Storage.SystemStorageEx; +using SecureFolderFS.UI; + +namespace SecureFolderFS.Uno.Platforms.Windows.ViewModels +{ + /// + /// Downloads an MSI installer asset from a GitHub release and installs it silently through msiexec. + /// + public abstract partial class MsiInstallationViewModel(string id, string? title) : ItemInstallationViewModel(id, title) + { + private const int ERROR_CANCELLED = 1223; // The user dismissed the UAC prompt + private const int ERROR_SUCCESS_REBOOT_REQUIRED = 3010; + private const int DOWNLOAD_BUFFER_SIZE = 81920; + + /// + /// Gets the owner of the GitHub repository to download the installer from. + /// + protected abstract string RepositoryOwner { get; } + + /// + /// Gets the name of the GitHub repository to download the installer from. + /// + protected abstract string RepositoryName { get; } + + /// + /// Gets the tag of the release to install. + /// + protected abstract string VersionTag { get; } + + /// + /// Picks the installer asset appropriate for this machine, or null when the release has none. + /// + protected abstract ReleaseAsset? SelectInstallerAsset(IReadOnlyList assets); + + /// + public override async Task InitAsync(CancellationToken cancellationToken = default) + { + var mediaService = DI.Service(); + Icon = await mediaService.GetImageFromResourceAsync(Id, cancellationToken); + } + + /// + protected override async Task InstallAsync(CancellationToken cancellationToken) + { + SystemFolderEx? tempFolder = null; + IChildFolder? installerFolder = null; + try + { + IsProgressing = true; + IsIndeterminate = true; + + var downloadUrl = await GetInstallerUrlAsync(cancellationToken); + + // A uniquely-named subfolder keeps the installer path unpredictable + // to other local processes before it is executed elevated + tempFolder = new SystemFolderEx(Path.GetTempPath()); + installerFolder = await tempFolder.CreateFolderAsync($"{nameof(SecureFolderFS)}_{Guid.NewGuid():N}", false, cancellationToken); + if (installerFolder is not IModifiableFolder modifiableFolder) + throw new InvalidOperationException("The temporary installer folder is not modifiable."); + + IsIndeterminate = false; + var installerFile = await DownloadFileAsync(downloadUrl, modifiableFolder, cancellationToken); + + IsIndeterminate = true; + await RunInstallerSilentlyAsync(installerFile, cancellationToken); + IsInstalled = true; + } + catch (OperationCanceledException) + { + // Cancellation, nothing to report + } + catch (Win32Exception ex) when (ex.NativeErrorCode == ERROR_CANCELLED) + { + // The user dismissed the UAC prompt - treat like cancellation + } + catch (Exception ex) + { + Report(Result.Failure(ex)); + } + finally + { + if (tempFolder is not null && installerFolder is not null) + _ = DeleteQuietlyAsync(tempFolder, installerFolder); + + IsProgressing = false; + IsIndeterminate = false; + } + + static async Task DeleteQuietlyAsync(IModifiableFolder parent, IStorableChild child) + { + try + { + await parent.DeleteAsync(child, CancellationToken.None); + } + catch (Exception) + { + // Best-effort cleanup of the temporary installer + } + } + } + + private async Task GetInstallerUrlAsync(CancellationToken cancellationToken) + { + var github = new GitHubClient(new ProductHeaderValue(Constants.GitHub.REPOSITORY_OWNER)); + var release = await github.Repository.Release.Get(RepositoryOwner, RepositoryName, VersionTag).WaitAsync(cancellationToken); + var asset = SelectInstallerAsset(release.Assets); + + return asset?.BrowserDownloadUrl + ?? throw new InvalidOperationException($"No installer found in the {RepositoryName} '{VersionTag}' release."); + } + + private async Task DownloadFileAsync(string url, IModifiableFolder installerFolder, CancellationToken cancellationToken) + { + using var httpClient = new HttpClient(); + using var response = await httpClient.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, cancellationToken); + response.EnsureSuccessStatusCode(); + + var totalBytes = response.Content.Headers.ContentLength ?? -1L; + await using var contentStream = await response.Content.ReadAsStreamAsync(cancellationToken); + + var downloadedFile = await installerFolder.CreateFileAsync(Path.GetFileName(url), true, cancellationToken); + await using var downloadedFileStream = await downloadedFile.OpenReadWriteAsync(cancellationToken); + + var buffer = new byte[DOWNLOAD_BUFFER_SIZE]; + var downloadedBytes = 0L; + int bytesRead; + + while ((bytesRead = await contentStream.ReadAsync(buffer, cancellationToken)) > 0) + { + await downloadedFileStream.WriteAsync(buffer.AsMemory(0, bytesRead), cancellationToken); + downloadedBytes += bytesRead; + + if (totalBytes > 0) + Report(downloadedBytes / (double)totalBytes * 100.0); + else + IsIndeterminate = true; // Content-Length unavailable, can't track progress + } + + return downloadedFile; + } + + private async Task RunInstallerSilentlyAsync(IFile installerFile, CancellationToken cancellationToken) + { + var processInfo = new ProcessStartInfo + { + FileName = "msiexec.exe", + Arguments = $"/i \"{installerFile.Id}\" /quiet /norestart", + UseShellExecute = true, + Verb = "runas", + CreateNoWindow = true + }; + + using var process = Process.Start(processInfo) + ?? throw new InvalidOperationException($"Failed to start the {Title} installer process."); + + await process.WaitForExitAsync(cancellationToken); + if (process.ExitCode is not (0 or ERROR_SUCCESS_REBOOT_REQUIRED)) + throw new InvalidOperationException($"The {Title} installer exited with code {process.ExitCode}."); + } + } +} diff --git a/src/Platforms/SecureFolderFS.Uno/Platforms/Windows/ViewModels/WinFspInstallationViewModel.cs b/src/Platforms/SecureFolderFS.Uno/Platforms/Windows/ViewModels/WinFspInstallationViewModel.cs index c398236aa..aa2b1b8a5 100644 --- a/src/Platforms/SecureFolderFS.Uno/Platforms/Windows/ViewModels/WinFspInstallationViewModel.cs +++ b/src/Platforms/SecureFolderFS.Uno/Platforms/Windows/ViewModels/WinFspInstallationViewModel.cs @@ -1,130 +1,31 @@ using System; -using System.Diagnostics; -using System.IO; -using System.Net.Http; -using System.Threading; -using System.Threading.Tasks; +using System.Collections.Generic; using Octokit; -using OwlCore.Storage; -using SecureFolderFS.Sdk.Services; -using SecureFolderFS.Sdk.ViewModels.Controls.Components; -using SecureFolderFS.Shared; -using SecureFolderFS.Shared.Models; -using SecureFolderFS.Storage.SystemStorageEx; -using SecureFolderFS.UI; namespace SecureFolderFS.Uno.Platforms.Windows.ViewModels { - /// - public sealed partial class WinFspInstallationViewModel() : ItemInstallationViewModel(Core.WinFsp.Constants.FileSystem.FS_ID, Core.WinFsp.Constants.FileSystem.FS_NAME) + /// + public sealed partial class WinFspInstallationViewModel() : MsiInstallationViewModel(Core.WinFsp.Constants.FileSystem.FS_ID, Core.WinFsp.Constants.FileSystem.FS_NAME) { - private const string OWNER = "winfsp"; - private const string REPO = "winfsp"; - /// - public override async Task InitAsync(CancellationToken cancellationToken = default) - { - var mediaService = DI.Service(); - Icon = await mediaService.GetImageFromResourceAsync(Id, cancellationToken); - } + protected override string RepositoryOwner { get; } = "winfsp"; /// - protected override async Task InstallAsync(CancellationToken cancellationToken) - { - try - { - IsIndeterminate = true; - - var (downloadUrl, fileName) = await GetInstallerAssetAsync(cancellationToken); - var installerFolder = new SystemFolderEx(Path.GetTempPath()); - - IsIndeterminate = false; - var downloadedFile = await DownloadFileAsync(downloadUrl, installerFolder, cancellationToken); - - IsIndeterminate = true; - await RunInstallerSilentlyAsync(downloadedFile, cancellationToken); - - await installerFolder.DeleteAsync(downloadedFile, cancellationToken); - IsInstalled = true; - } - catch (Exception ex) - { - Report(Result.Failure(ex)); - } - finally - { - IsProgressing = false; - IsIndeterminate = false; - } - } - - private static async Task<(string downloadUrl, string fileName)> GetInstallerAssetAsync(CancellationToken cancellationToken) - { - var github = new GitHubClient(new ProductHeaderValue(Constants.GitHub.REPOSITORY_OWNER)); - var release = await github.Repository.Release.Get(OWNER, REPO, Core.WinFsp.Constants.FileSystem.VERSION_TAG).WaitAsync(cancellationToken); + protected override string RepositoryName { get; } = "winfsp"; - ReleaseAsset? asset = null; - foreach (var item in release.Assets) - { - if (!item.Name.EndsWith(".msi", StringComparison.OrdinalIgnoreCase)) - continue; - - asset = item; - break; - } - - return asset is not null - ? (asset.BrowserDownloadUrl, asset.Name) - : throw new InvalidOperationException("No MSI installer found in the WinFsp release."); - } + /// + protected override string VersionTag { get; } = Core.WinFsp.Constants.FileSystem.VERSION_TAG; - private async Task DownloadFileAsync(string url, IModifiableFolder installerFolder, CancellationToken cancellationToken) + /// + protected override ReleaseAsset? SelectInstallerAsset(IReadOnlyList assets) { - using var httpClient = new HttpClient(); - using var response = await httpClient.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, cancellationToken); - response.EnsureSuccessStatusCode(); - - var totalBytes = response.Content.Headers.ContentLength ?? -1L; - await using var contentStream = await response.Content.ReadAsStreamAsync(cancellationToken); - - var downloadedFile = await installerFolder.CreateFileAsync($"{nameof(SecureFolderFS)}_{Path.GetFileName(url)}", true, cancellationToken); - await using var downloadedFileStream = await downloadedFile.OpenReadWriteAsync(cancellationToken); - - var buffer = new byte[4096]; - var downloadedBytes = 0L; - int bytesRead; - - while ((bytesRead = await contentStream.ReadAsync(buffer, cancellationToken)) > 0) + foreach (var item in assets) { - await downloadedFileStream.WriteAsync(buffer.AsMemory(0, bytesRead), cancellationToken); - downloadedBytes += bytesRead; - - if (totalBytes > 0) - Report(downloadedBytes / (double)totalBytes * 100.0); - else - IsIndeterminate = true; + if (item.Name.EndsWith(".msi", StringComparison.OrdinalIgnoreCase)) + return item; } - return downloadedFile; - } - - private static async Task RunInstallerSilentlyAsync(IFile downloadedFile, CancellationToken cancellationToken) - { - var processInfo = new ProcessStartInfo - { - FileName = "msiexec.exe", - Arguments = $"/i \"{downloadedFile.Id}\" /quiet /norestart", - UseShellExecute = true, - Verb = "runas", - CreateNoWindow = true - }; - - using var process = Process.Start(processInfo) - ?? throw new InvalidOperationException("Failed to start the WinFsp installer process."); - - await process.WaitForExitAsync(cancellationToken); - if (process.ExitCode is not (0 or 3010)) - throw new InvalidOperationException($"WinFsp installer exited with code {process.ExitCode}."); + return null; } } } diff --git a/src/Platforms/SecureFolderFS.Uno/UserControls/Introduction/FileSystemSlide.xaml b/src/Platforms/SecureFolderFS.Uno/UserControls/Introduction/FileSystemSlide.xaml index 08a908ed3..b222164a5 100644 --- a/src/Platforms/SecureFolderFS.Uno/UserControls/Introduction/FileSystemSlide.xaml +++ b/src/Platforms/SecureFolderFS.Uno/UserControls/Introduction/FileSystemSlide.xaml @@ -207,16 +207,26 @@ - - - - - + + + + + + + + + + diff --git a/src/Platforms/SecureFolderFS.Uno/UserControls/Introduction/FileSystemSlide.xaml.cs b/src/Platforms/SecureFolderFS.Uno/UserControls/Introduction/FileSystemSlide.xaml.cs index 197c1a8ee..35167b88d 100644 --- a/src/Platforms/SecureFolderFS.Uno/UserControls/Introduction/FileSystemSlide.xaml.cs +++ b/src/Platforms/SecureFolderFS.Uno/UserControls/Introduction/FileSystemSlide.xaml.cs @@ -1,17 +1,11 @@ using System; using System.Collections.Generic; -using System.IO; +using System.Collections.Specialized; using System.Linq; -using System.Runtime.InteropServices.WindowsRuntime; -using Windows.Foundation; -using Windows.Foundation.Collections; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; -using Microsoft.UI.Xaml.Controls.Primitives; -using Microsoft.UI.Xaml.Data; -using Microsoft.UI.Xaml.Input; -using Microsoft.UI.Xaml.Media; -using Microsoft.UI.Xaml.Navigation; +using SecureFolderFS.Sdk.EventArguments; +using SecureFolderFS.Sdk.Extensions; using SecureFolderFS.Sdk.ViewModels.Controls.Components; using SecureFolderFS.Sdk.ViewModels.Views.Overlays; using SecureFolderFS.Shared.Extensions; @@ -41,13 +35,59 @@ private void FileSystems_ItemClick(object sender, ItemClickEventArgs e) OverlayViewModel.SelectedFileSystem = itemViewModel; } + private void FileSystems_CollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) + { + if (e.NewItems is null) + return; + + foreach (var installation in e.NewItems.OfType()) + SubscribeInstallation(installation); + } + + private void Installation_StateChanged(object? sender, EventArgs e) + { + if (e is not ErrorReportedEventArgs { Result.Successful: false } args) + return; + + // Installation failures are actionable (the user can retry), so surface them in-flow + InstallationErrorTip.Subtitle = args.Result.GetMessage("UnknownError".ToLocalized()); + InstallationErrorTip.IsOpen = true; + } + + private void AttachInstallationHandlers(IntroductionOverlayViewModel? oldViewModel) + { + if (oldViewModel is not null) + oldViewModel.FileSystems.CollectionChanged -= FileSystems_CollectionChanged; + + foreach (var installation in _subscribedInstallations) + installation.StateChanged -= Installation_StateChanged; + _subscribedInstallations.Clear(); + + if (OverlayViewModel is null) + return; + + // The list may still be initializing, so watch for late-added items as well + OverlayViewModel.FileSystems.CollectionChanged += FileSystems_CollectionChanged; + foreach (var installation in OverlayViewModel.FileSystems.OfType()) + SubscribeInstallation(installation); + } + + private void SubscribeInstallation(ItemInstallationViewModel installation) + { + installation.StateChanged += Installation_StateChanged; + _subscribedInstallations.Add(installation); + } + + private readonly List _subscribedInstallations = new(); + public IntroductionOverlayViewModel? OverlayViewModel { get => (IntroductionOverlayViewModel?)GetValue(OverlayViewModelProperty); set => SetValue(OverlayViewModelProperty, value); } public static readonly DependencyProperty OverlayViewModelProperty = - DependencyProperty.Register(nameof(OverlayViewModel), typeof(IntroductionOverlayViewModel), typeof(FileSystemSlide), new PropertyMetadata(null)); + DependencyProperty.Register(nameof(OverlayViewModel), typeof(IntroductionOverlayViewModel), typeof(FileSystemSlide), + new PropertyMetadata(null, static (sender, e) => ((FileSystemSlide)sender).AttachInstallationHandlers(e.OldValue as IntroductionOverlayViewModel))); } }