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)));
}
}