1.1.0
This commit is contained in:
1 parent
e7ed05922e
commit
cc08b00e3b
20 files changed
+1318
-32
No files matched your search
+48
-8
@@ -12,6 +12,7 @@ using Kniip.Interop;
|
||||
using Kniip.Notifications;
|
||||
using Kniip.Recording;
|
||||
using Kniip.Settings;
|
||||
using Kniip.Update;
|
||||
using Microsoft.Win32;
|
||||
using Screen = System.Windows.Forms.Screen;
|
||||
|
||||
@@ -23,6 +24,7 @@ public partial class App : Application
|
||||
private TrayIcon? _tray;
|
||||
private SettingsWindow? _settingsWindow;
|
||||
private readonly List<RegionCaptureWindow> _snipWindows = new();
|
||||
private List<SnappedWindow> _snipSnapshot = new();
|
||||
private uint? _ignoredClipboardSequence;
|
||||
|
||||
public static App Instance => (App)Current;
|
||||
@@ -134,6 +136,12 @@ public partial class App : Application
|
||||
OpenFile(file);
|
||||
}
|
||||
|
||||
if (args.Contains("--updated", StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
_tray?.ShowBalloon("kniip is bijgewerkt", $"Je gebruikt nu versie {UpdateChecker.CurrentVersion}.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (files.Length > 0 || args.Contains("--tray", StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
return;
|
||||
@@ -163,9 +171,10 @@ public partial class App : Application
|
||||
}
|
||||
}
|
||||
|
||||
public void OpenEditor(BitmapSource image)
|
||||
/// <param name="windowName">Programmanaam van het geknipte venster, voor %V in het naamsjabloon.</param>
|
||||
public void OpenEditor(BitmapSource image, string? windowName = null)
|
||||
{
|
||||
var editor = new EditorWindow(image, Settings);
|
||||
var editor = new EditorWindow(image, Settings, windowName: windowName);
|
||||
editor.Show();
|
||||
BringToFront(editor);
|
||||
}
|
||||
@@ -200,7 +209,8 @@ public partial class App : Application
|
||||
var cursorPoint = new System.Drawing.Point(cursor.X, cursor.Y);
|
||||
|
||||
// Eerst alle schermen vastleggen, dan pas vensters tonen, anders staat de overlay in het beeld.
|
||||
List<System.Drawing.Rectangle>? windows = pickWindow ? PhysicalWindow.SnapshotWindows(includeOwn: true) : null;
|
||||
_snipSnapshot = PhysicalWindow.Snapshot(includeOwn: true);
|
||||
List<System.Drawing.Rectangle>? windows = pickWindow ? _snipSnapshot.Select(w => w.Bounds).ToList() : null;
|
||||
var frozen = Screen.AllScreens.Select(screen => (screen, ScreenGrabber.Capture(screen.Bounds))).ToList();
|
||||
foreach ((Screen screen, BitmapSource image) in frozen)
|
||||
{
|
||||
@@ -249,6 +259,7 @@ public partial class App : Application
|
||||
HotkeyHook.Capture = null;
|
||||
}
|
||||
|
||||
System.Drawing.Rectangle? area = _snipWindows.Select(w => w.SelectedArea).FirstOrDefault(a => a != null);
|
||||
foreach (RegionCaptureWindow window in _snipWindows)
|
||||
{
|
||||
window.Finished -= OnRegionSnipFinished;
|
||||
@@ -256,9 +267,12 @@ public partial class App : Application
|
||||
}
|
||||
|
||||
_snipWindows.Clear();
|
||||
List<SnappedWindow> snapshot = _snipSnapshot;
|
||||
_snipSnapshot = new();
|
||||
if (image != null)
|
||||
{
|
||||
OpenEditor(image);
|
||||
string? windowName = area is { } a ? PhysicalWindow.ProgramNameAt(snapshot, a) : null;
|
||||
OpenEditor(image, windowName);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -325,9 +339,9 @@ public partial class App : Application
|
||||
private void UpdateCapturePaused() =>
|
||||
Capture.Paused = (_tray?.IsPaused ?? false) || Recorder.Stage != RecordingStage.Idle;
|
||||
|
||||
private void OpenScreencast(string path, ScreencastFormat? format)
|
||||
private void OpenScreencast(string path, ScreencastFormat? format, string? windowName)
|
||||
{
|
||||
var window = new ScreencastWindow(path, Settings, format);
|
||||
var window = new ScreencastWindow(path, Settings, format, windowName);
|
||||
window.Show();
|
||||
BringToFront(window);
|
||||
}
|
||||
@@ -441,12 +455,21 @@ public partial class App : Application
|
||||
}
|
||||
|
||||
private void ExitApplication()
|
||||
{
|
||||
if (TryCloseAllWindows())
|
||||
{
|
||||
Shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Sluit alle vensters; false als een screencast loopt of een editor open blijft.</summary>
|
||||
private bool TryCloseAllWindows()
|
||||
{
|
||||
if (Recorder.Stage != RecordingStage.Idle)
|
||||
{
|
||||
MessageBox.Show("Er loopt nog een screencast. Stop of annuleer die eerst.", "kniip",
|
||||
MessageBoxButton.OK, MessageBoxImage.Information);
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (Window window in Windows.Cast<Window>().ToList())
|
||||
@@ -454,11 +477,28 @@ public partial class App : Application
|
||||
window.Close();
|
||||
}
|
||||
|
||||
if (Windows.OfType<EditorWindow>().Any())
|
||||
return !Windows.OfType<EditorWindow>().Any();
|
||||
}
|
||||
|
||||
/// <summary>Start de gedownloade installer en sluit kniip; de installer start kniip daarna opnieuw.</summary>
|
||||
public void InstallUpdate(string installerPath)
|
||||
{
|
||||
if (!TryCloseAllWindows())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
UpdateInstaller.Start(installerPath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MessageBox.Show($"De installer kon niet worden gestart.\n\n{ex.Message}", "kniip",
|
||||
MessageBoxButton.OK, MessageBoxImage.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
Shutdown();
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,9 @@ public partial class RegionCaptureWindow : Window
|
||||
/// <summary>Het knipsel, of null bij annuleren.</summary>
|
||||
internal event Action<BitmapSource?>? Finished;
|
||||
|
||||
/// <summary>Geknipt gebied in fysieke schermcoördinaten, gezet vlak voor Finished.</summary>
|
||||
internal Rectangle? SelectedArea { get; private set; }
|
||||
|
||||
/// <param name="windows">Vensterrechthoeken van boven naar onder; gezet = venster aanklikken in plaats van slepen.</param>
|
||||
internal RegionCaptureWindow(Screen screen, BitmapSource frozen, IReadOnlyList<Rectangle>? windows = null)
|
||||
{
|
||||
@@ -164,6 +167,7 @@ public partial class RegionCaptureWindow : Window
|
||||
}
|
||||
|
||||
var relative = new Int32Rect(clipped.X - _screen.Bounds.X, clipped.Y - _screen.Bounds.Y, clipped.Width, clipped.Height);
|
||||
SelectedArea = clipped;
|
||||
Finished?.Invoke(ScreenGrabber.Crop(_frozen, relative));
|
||||
}
|
||||
|
||||
|
||||
@@ -68,11 +68,14 @@ public partial class EditorWindow : Window
|
||||
private bool _dirty;
|
||||
private bool _discarding;
|
||||
private bool _exported;
|
||||
private readonly string? _windowName;
|
||||
|
||||
public EditorWindow(BitmapSource image, AppSettings settings, bool isBlank = false)
|
||||
/// <param name="windowName">Programmanaam van het geknipte venster, voor %V in het naamsjabloon.</param>
|
||||
public EditorWindow(BitmapSource image, AppSettings settings, bool isBlank = false, string? windowName = null)
|
||||
{
|
||||
InitializeComponent();
|
||||
_settings = settings;
|
||||
_windowName = windowName;
|
||||
_doc = new EditorDocument(image);
|
||||
|
||||
Surface.Document = _doc;
|
||||
@@ -1430,6 +1433,8 @@ public partial class EditorWindow : Window
|
||||
}
|
||||
}
|
||||
|
||||
private static string DefaultName => $"Knipsel {DateTime.Now:yyyy-MM-dd HHmmss}";
|
||||
|
||||
private bool Save()
|
||||
{
|
||||
if (_settings.SnipSaveFolder is not { Length: > 0 } target)
|
||||
@@ -1441,8 +1446,7 @@ public partial class EditorWindow : Window
|
||||
string path;
|
||||
try
|
||||
{
|
||||
System.IO.Directory.CreateDirectory(target);
|
||||
path = SaveLocation.UniquePath(target, $"Knipsel {DateTime.Now:yyyy-MM-dd HHmmss}.png");
|
||||
path = FileNameTemplate.Resolve(target, _settings.SnipFileNameTemplate, _windowName ?? "Knipsel", DefaultName, ".png");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -1464,7 +1468,7 @@ public partial class EditorWindow : Window
|
||||
{
|
||||
Title = "Knipsel opslaan",
|
||||
Filter = "PNG-afbeelding (*.png)|*.png|JPEG-afbeelding (*.jpg)|*.jpg",
|
||||
FileName = $"Knipsel {DateTime.Now:yyyy-MM-dd HHmmss}",
|
||||
FileName = FileNameTemplate.SuggestedName(_settings.SnipFileNameTemplate, _windowName ?? "Knipsel", DefaultName),
|
||||
DefaultExt = ".png",
|
||||
InitialDirectory = folder,
|
||||
AddExtension = true
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||
<Product>kniip</Product>
|
||||
<Description>Annotatie-editor naast het Windows Knipprogramma</Description>
|
||||
<Version>1.0.0</Version>
|
||||
<Version>1.1.0</Version>
|
||||
<SatelliteResourceLanguages>nl;en</SatelliteResourceLanguages>
|
||||
<!-- WPF regelt DPI via app.manifest; de WinForms-analyzer is hier niet van toepassing. -->
|
||||
<NoWarn>$(NoWarn);WFAC010</NoWarn>
|
||||
@@ -32,6 +32,7 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Markdig" Version="1.6.0" />
|
||||
<PackageReference Include="ScreenRecorderLib" Version="7.0.1" />
|
||||
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="8.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Drawing;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Windows;
|
||||
using System.Windows.Interop;
|
||||
@@ -39,9 +41,37 @@ internal static class PhysicalWindow
|
||||
NativeMethods.SetWindowDisplayAffinity(Handle(window), NativeMethods.WDA_EXCLUDEFROMCAPTURE);
|
||||
|
||||
/// <summary>Zichtbare topniveauvensters, van boven naar onder; die van kniip zelf alleen met <paramref name="includeOwn"/>.</summary>
|
||||
public static List<Rectangle> SnapshotWindows(bool includeOwn = false)
|
||||
public static List<Rectangle> SnapshotWindows(bool includeOwn = false) =>
|
||||
Snapshot(includeOwn).Select(w => w.Bounds).ToList();
|
||||
|
||||
/// <summary>Programmanaam van het bovenste venster uit <paramref name="windows"/> onder het midden van <paramref name="area"/>.</summary>
|
||||
public static string? ProgramNameAt(IEnumerable<SnappedWindow> windows, Rectangle area)
|
||||
{
|
||||
var result = new List<Rectangle>();
|
||||
var point = new System.Drawing.Point(area.X + area.Width / 2, area.Y + area.Height / 2);
|
||||
foreach (SnappedWindow window in windows)
|
||||
{
|
||||
if (!window.Bounds.Contains(point))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using Process process = Process.GetProcessById((int)window.ProcessId);
|
||||
return process.ProcessName;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public static List<SnappedWindow> Snapshot(bool includeOwn = false)
|
||||
{
|
||||
var result = new List<SnappedWindow>();
|
||||
int ownPid = Environment.ProcessId;
|
||||
var className = new StringBuilder(64);
|
||||
NativeMethods.EnumWindows((hwnd, _) =>
|
||||
@@ -69,7 +99,7 @@ internal static class PhysicalWindow
|
||||
NativeMethods.RECT rect = NativeMethods.GetVisibleWindowRect(hwnd);
|
||||
if (rect.Width > 8 && rect.Height > 8)
|
||||
{
|
||||
result.Add(new Rectangle(rect.Left, rect.Top, rect.Width, rect.Height));
|
||||
result.Add(new SnappedWindow(new Rectangle(rect.Left, rect.Top, rect.Width, rect.Height), pid));
|
||||
}
|
||||
|
||||
return true;
|
||||
@@ -78,3 +108,5 @@ internal static class PhysicalWindow
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record SnappedWindow(Rectangle Bounds, uint ProcessId);
|
||||
@@ -52,8 +52,11 @@ internal sealed class RecordingController
|
||||
|
||||
public event Action<RecordingStage>? StageChanged;
|
||||
|
||||
/// <summary>Pad van een geslaagde opname (tijdelijk MP4-bestand) en het voorgestelde formaat.</summary>
|
||||
public event Action<string, ScreencastFormat?>? RecordingFinished;
|
||||
/// <summary>Pad van een geslaagde opname (tijdelijk MP4-bestand), het voorgestelde formaat en de programmanaam van het opgenomen venster.</summary>
|
||||
public event Action<string, ScreencastFormat?, string?>? RecordingFinished;
|
||||
|
||||
private List<SnappedWindow> _snapshot = new();
|
||||
private string? _windowName;
|
||||
|
||||
public event Action<string, string>? Notify;
|
||||
|
||||
@@ -124,7 +127,8 @@ internal sealed class RecordingController
|
||||
{
|
||||
_format = format;
|
||||
SetStage(RecordingStage.Selecting);
|
||||
List<System.Drawing.Rectangle> windows = PhysicalWindow.SnapshotWindows();
|
||||
_snapshot = PhysicalWindow.Snapshot();
|
||||
List<System.Drawing.Rectangle> windows = _snapshot.Select(w => w.Bounds).ToList();
|
||||
NativeMethods.GetCursorPos(out NativeMethods.POINT cursor);
|
||||
var cursorPoint = new System.Drawing.Point(cursor.X, cursor.Y);
|
||||
|
||||
@@ -150,6 +154,8 @@ internal sealed class RecordingController
|
||||
}
|
||||
|
||||
_selectors.Clear();
|
||||
List<SnappedWindow> snapshot = _snapshot;
|
||||
_snapshot = new();
|
||||
if (region == null)
|
||||
{
|
||||
SetStage(RecordingStage.Idle);
|
||||
@@ -157,6 +163,7 @@ internal sealed class RecordingController
|
||||
}
|
||||
|
||||
_region = region;
|
||||
_windowName = PhysicalWindow.ProgramNameAt(snapshot, region.Bounds);
|
||||
Screen screen = Screen.AllScreens.FirstOrDefault(s => s.DeviceName == region.DeviceName) ?? Screen.PrimaryScreen!;
|
||||
|
||||
_frame = new RegionFrameWindow(region.Bounds);
|
||||
@@ -299,6 +306,7 @@ internal sealed class RecordingController
|
||||
{
|
||||
bool discard = _discard;
|
||||
ScreencastFormat? format = _format;
|
||||
string? windowName = _windowName;
|
||||
EndAll();
|
||||
if (discard)
|
||||
{
|
||||
@@ -306,7 +314,7 @@ internal sealed class RecordingController
|
||||
return;
|
||||
}
|
||||
|
||||
RecordingFinished?.Invoke(path, format);
|
||||
RecordingFinished?.Invoke(path, format, windowName);
|
||||
}
|
||||
|
||||
private void OnFailed(string error, bool started)
|
||||
@@ -360,6 +368,7 @@ internal sealed class RecordingController
|
||||
_frame?.Close();
|
||||
_frame = null;
|
||||
_region = null;
|
||||
_windowName = null;
|
||||
_format = null;
|
||||
_discard = false;
|
||||
_retriedWithoutAudio = false;
|
||||
|
||||
@@ -34,6 +34,7 @@ public partial class ScreencastWindow : Window
|
||||
|
||||
private readonly string _path;
|
||||
private readonly AppSettings _settings;
|
||||
private readonly string? _windowName;
|
||||
private readonly DispatcherTimer _playTimer = new() { Interval = TimeSpan.FromMilliseconds(40) };
|
||||
private TimeSpan _duration;
|
||||
private TimeSpan _start;
|
||||
@@ -45,11 +46,13 @@ public partial class ScreencastWindow : Window
|
||||
private bool _kept;
|
||||
private string? _lastSaved;
|
||||
|
||||
internal ScreencastWindow(string path, AppSettings settings, ScreencastFormat? format = null)
|
||||
/// <param name="windowName">Programmanaam van het opgenomen venster, voor %V in het naamsjabloon.</param>
|
||||
internal ScreencastWindow(string path, AppSettings settings, ScreencastFormat? format = null, string? windowName = null)
|
||||
{
|
||||
InitializeComponent();
|
||||
_path = path;
|
||||
_settings = settings;
|
||||
_windowName = windowName;
|
||||
|
||||
int[] fpsChoices = FpsChoices.Contains(settings.GifFps) ? FpsChoices : FpsChoices.Append(settings.GifFps).Order().ToArray();
|
||||
foreach (int fps in fpsChoices)
|
||||
@@ -382,8 +385,11 @@ public partial class ScreencastWindow : Window
|
||||
}
|
||||
}
|
||||
|
||||
private string DefaultFileName =>
|
||||
$"Screencast {DateTime.Now:yyyy-MM-dd HHmmss}{(Format == ScreencastFormat.Gif ? ".gif" : ".mp4")}";
|
||||
private string Extension => Format == ScreencastFormat.Gif ? ".gif" : ".mp4";
|
||||
|
||||
private static string DefaultName => $"Screencast {DateTime.Now:yyyy-MM-dd HHmmss}";
|
||||
|
||||
private string DefaultFileName => DefaultName + Extension;
|
||||
|
||||
private static string DefaultFolder =>
|
||||
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.MyVideos), "kniip");
|
||||
@@ -435,8 +441,7 @@ public partial class ScreencastWindow : Window
|
||||
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(folder);
|
||||
return SaveLocation.UniquePath(folder, DefaultFileName);
|
||||
return FileNameTemplate.Resolve(folder, _settings.VideoFileNameTemplate, _windowName ?? "Screencast", DefaultName, Extension);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -455,7 +460,7 @@ public partial class ScreencastWindow : Window
|
||||
{
|
||||
Title = "Screencast opslaan",
|
||||
Filter = gif ? "GIF-animatie (*.gif)|*.gif" : "MP4-video (*.mp4)|*.mp4",
|
||||
FileName = DefaultFileName,
|
||||
FileName = FileNameTemplate.SuggestedName(_settings.VideoFileNameTemplate, _windowName ?? "Screencast", DefaultName) + Extension,
|
||||
InitialDirectory = folder,
|
||||
AddExtension = true
|
||||
};
|
||||
|
||||
@@ -44,6 +44,9 @@ public sealed class AppSettings
|
||||
|
||||
/// <summary>Map waarin knipsels zonder te vragen worden opgeslagen; leeg betekent elke keer vragen.</summary>
|
||||
public string? SnipSaveFolder { get; set; }
|
||||
|
||||
/// <summary>Naamsjabloon voor knipsels (zie FileNameTemplate); leeg betekent "Knipsel" met datum en tijd.</summary>
|
||||
public string? SnipFileNameTemplate { get; set; }
|
||||
public Dictionary<ToolKind, StyleSet> ToolStyles { get; set; } = new();
|
||||
|
||||
/// <summary>Recent gekozen emoji (codepunten), nieuwste eerst.</summary>
|
||||
@@ -73,6 +76,9 @@ public sealed class AppSettings
|
||||
/// <summary>Map waarin screencasts zonder te vragen worden opgeslagen; leeg betekent elke keer vragen.</summary>
|
||||
public string? VideoSaveFolder { get; set; }
|
||||
|
||||
/// <summary>Naamsjabloon voor screencasts (zie FileNameTemplate); leeg betekent "Screencast" met datum en tijd.</summary>
|
||||
public string? VideoFileNameTemplate { get; set; }
|
||||
|
||||
/// <summary>Gedrag van de knop Opslaan in de editor en de screencast.</summary>
|
||||
public SaveButtonChoice SaveButton { get; set; } = SaveButtonChoice.SaveOnly;
|
||||
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace Kniip.Settings;
|
||||
|
||||
/// <summary>
|
||||
/// Naamsjabloon voor opslaan: %J jaar, %M maand, %D dag, %V programmanaam van het venster,
|
||||
/// %Rn n willekeurige tekens, %Nn eerste vrije nummer in de map met n cijfers. / of \ maakt submappen.
|
||||
/// </summary>
|
||||
public static class FileNameTemplate
|
||||
{
|
||||
private const int MaxWidth = 32;
|
||||
private const string RandomChars = "abcdefghijklmnopqrstuvwxyz0123456789";
|
||||
|
||||
private static readonly Regex Token = new(@"%(?:(?<k>[JMDV])|(?<k>[RN])(?<n>\d{1,2}))",
|
||||
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
|
||||
|
||||
public const string Help =
|
||||
"%J jaar, %M maand, %D dag, %V programmanaam van het venster, %R6 zes willekeurige tekens, %N3 oplopend nummer met drie cijfers (001). " +
|
||||
"Met / maak je submappen. De extensie kiest kniip zelf.";
|
||||
|
||||
/// <summary>
|
||||
/// Volledig pad in <paramref name="folder"/> dat nog niet bestaat; de submappen worden aangemaakt.
|
||||
/// Een leeg sjabloon geeft <paramref name="defaultName"/>.
|
||||
/// </summary>
|
||||
public static string Resolve(string folder, string? template, string windowName, string defaultName, string extension)
|
||||
{
|
||||
var randoms = new Dictionary<int, string>();
|
||||
DateTime now = DateTime.Now;
|
||||
bool counts = HasCounter(template);
|
||||
for (int number = 1; ; number++)
|
||||
{
|
||||
List<string> parts = Parts(template, windowName, defaultName, now, number, randoms);
|
||||
string directory = Path.Combine(new[] { folder }.Concat(parts.SkipLast(1)).ToArray());
|
||||
Directory.CreateDirectory(directory);
|
||||
string fileName = parts[^1] + extension;
|
||||
if (!counts)
|
||||
{
|
||||
return SaveLocation.UniquePath(directory, fileName);
|
||||
}
|
||||
|
||||
string path = Path.Combine(directory, fileName);
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
return path;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Voorbeeld van het pad onder de opslagmap, zonder naar bestaande bestanden te kijken.</summary>
|
||||
public static string Preview(string? template, string windowName, string defaultName, string extension) =>
|
||||
string.Join(Path.DirectorySeparatorChar, Parts(template, windowName, defaultName, DateTime.Now, 1, new Dictionary<int, string>())) + extension;
|
||||
|
||||
/// <summary>Alleen de bestandsnaam zonder extensie, als voorstel in een opslagvenster.</summary>
|
||||
public static string SuggestedName(string? template, string windowName, string defaultName) =>
|
||||
Parts(template, windowName, defaultName, DateTime.Now, 1, new Dictionary<int, string>())[^1];
|
||||
|
||||
private static bool HasCounter(string? template) =>
|
||||
!string.IsNullOrWhiteSpace(template)
|
||||
&& Token.Matches(template).Any(m => char.ToUpperInvariant(m.Groups["k"].Value[0]) == 'N');
|
||||
|
||||
/// <summary>Opgeschoonde mapnamen gevolgd door de bestandsnaam zonder extensie.</summary>
|
||||
private static List<string> Parts(string? template, string windowName, string defaultName, DateTime now, int number, Dictionary<int, string> randoms)
|
||||
{
|
||||
var parts = new List<string>();
|
||||
if (!string.IsNullOrWhiteSpace(template))
|
||||
{
|
||||
string expanded = Token.Replace(template, m => Value(m, windowName, now, number, randoms));
|
||||
parts.AddRange(expanded.Split('/', '\\').Select(Clean).Where(p => p.Length > 0));
|
||||
}
|
||||
|
||||
if (parts.Count == 0 || template!.TrimEnd().EndsWith('/') || template.TrimEnd().EndsWith('\\'))
|
||||
{
|
||||
parts.Add(Clean(defaultName));
|
||||
}
|
||||
|
||||
return parts;
|
||||
}
|
||||
|
||||
private static string Value(Match match, string windowName, DateTime now, int number, Dictionary<int, string> randoms)
|
||||
{
|
||||
int width = match.Groups["n"].Success ? Math.Min(int.Parse(match.Groups["n"].Value, CultureInfo.InvariantCulture), MaxWidth) : 0;
|
||||
switch (char.ToUpperInvariant(match.Groups["k"].Value[0]))
|
||||
{
|
||||
case 'J':
|
||||
return now.Year.ToString(CultureInfo.InvariantCulture);
|
||||
case 'M':
|
||||
return now.Month.ToString("00", CultureInfo.InvariantCulture);
|
||||
case 'D':
|
||||
return now.Day.ToString("00", CultureInfo.InvariantCulture);
|
||||
case 'V':
|
||||
return windowName.Replace('/', '_').Replace('\\', '_');
|
||||
case 'R':
|
||||
if (!randoms.TryGetValue(match.Index, out string? random))
|
||||
{
|
||||
random = new string(Enumerable.Range(0, width).Select(_ => RandomChars[RandomNumberGenerator.GetInt32(RandomChars.Length)]).ToArray());
|
||||
randoms[match.Index] = random;
|
||||
}
|
||||
|
||||
return random;
|
||||
default:
|
||||
return number.ToString(CultureInfo.InvariantCulture).PadLeft(width, '0');
|
||||
}
|
||||
}
|
||||
|
||||
private static string Clean(string segment)
|
||||
{
|
||||
char[] invalid = Path.GetInvalidFileNameChars();
|
||||
string cleaned = new(segment.Select(c => invalid.Contains(c) ? '_' : c).ToArray());
|
||||
return cleaned.Trim().TrimEnd('.').Trim();
|
||||
}
|
||||
}
|
||||
@@ -163,6 +163,12 @@
|
||||
Margin="6,0,0,0" />
|
||||
<TextBlock x:Name="SnipFolderText" VerticalAlignment="Center" TextTrimming="CharacterEllipsis" />
|
||||
</DockPanel>
|
||||
<DockPanel Margin="0,0,0,2">
|
||||
<TextBlock Text="Naam" VerticalAlignment="Center" Width="110" Padding="12,0,0,0" />
|
||||
<TextBox x:Name="SnipTemplateBox" Height="26" VerticalContentAlignment="Center" TextChanged="Template_TextChanged"
|
||||
ToolTip="Leeg: Knipsel met datum en tijd" />
|
||||
</DockPanel>
|
||||
<TextBlock x:Name="SnipTemplatePreview" Style="{StaticResource Hint}" Margin="110,0,0,8" TextTrimming="CharacterEllipsis" />
|
||||
<DockPanel Margin="0,0,0,4">
|
||||
<TextBlock Text="Screencasts" VerticalAlignment="Center" Width="110" />
|
||||
<Button Tag="Video" DockPanel.Dock="Right" Style="{StaticResource FlatButton}" Content="Vragen" Click="ClearSaveFolder_Click"
|
||||
@@ -171,8 +177,15 @@
|
||||
Margin="6,0,0,0" />
|
||||
<TextBlock x:Name="VideoFolderText" VerticalAlignment="Center" TextTrimming="CharacterEllipsis" />
|
||||
</DockPanel>
|
||||
<DockPanel Margin="0,0,0,2">
|
||||
<TextBlock Text="Naam" VerticalAlignment="Center" Width="110" Padding="12,0,0,0" />
|
||||
<TextBox x:Name="VideoTemplateBox" Height="26" VerticalContentAlignment="Center" TextChanged="Template_TextChanged"
|
||||
ToolTip="Leeg: Screencast met datum en tijd" />
|
||||
</DockPanel>
|
||||
<TextBlock x:Name="VideoTemplatePreview" Style="{StaticResource Hint}" Margin="110,0,0,8" TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock x:Name="TemplateHelp" Style="{StaticResource Hint}" Margin="0,0,0,4" />
|
||||
<TextBlock Style="{StaticResource Hint}" Margin="0,4,0,8"
|
||||
Text="Met een map ingesteld slaat Opslaan (Ctrl+S) direct op, met datum en tijd in de naam. Shift+klik of Ctrl+Shift+S vraagt toch waar het bestand moet komen." />
|
||||
Text="Met een map ingesteld slaat Opslaan (Ctrl+S) direct op, met de naam hierboven. Shift+klik of Ctrl+Shift+S vraagt toch waar het bestand moet komen." />
|
||||
<RadioButton x:Name="SaveOnlyRadio" GroupName="SaveButton" Content="Opslaan" />
|
||||
<RadioButton x:Name="SaveAndCopyRadio" GroupName="SaveButton" Content="Opslaan en kopiëren" />
|
||||
<TextBlock Style="{StaticResource Hint}"
|
||||
@@ -238,6 +251,29 @@
|
||||
<CheckBox x:Name="StraightHighlightBox" Content="Markeerstift trekt standaard een rechte horizontale streep (Shift wisselt)" />
|
||||
</StackPanel>
|
||||
</TabItem>
|
||||
|
||||
<TabItem x:Name="UpdateTab" Header="Update">
|
||||
<StackPanel>
|
||||
<TextBlock Text="Update" Style="{StaticResource CardTitle}" />
|
||||
<TextBlock x:Name="CurrentVersionText" Margin="0,0,0,2" />
|
||||
<TextBlock x:Name="UpdateStatus" Style="{StaticResource Hint}" Margin="0,0,0,8" />
|
||||
<StackPanel Orientation="Horizontal" Margin="0,0,0,8">
|
||||
<Button x:Name="UpdateButton" Style="{StaticResource AccentButton}" Click="Update_Click" Margin="0,0,6,0"
|
||||
Visibility="Collapsed" ToolTip="Installer downloaden; kniip sluit, wordt bijgewerkt en start opnieuw" />
|
||||
<Button x:Name="CheckUpdateButton" Style="{StaticResource SecondaryButton}" Content="Opnieuw controleren"
|
||||
Click="CheckUpdate_Click" Margin="0,0,6,0" />
|
||||
<Button Style="{StaticResource FlatButton}" Content="Releasepagina" Click="ReleasePage_Click"
|
||||
ToolTip="https://git.hibbit.net/rogier/kniip/releases" />
|
||||
</StackPanel>
|
||||
<ProgressBar x:Name="DownloadProgress" Height="4" Minimum="0" Maximum="1" Margin="0,0,0,8"
|
||||
Foreground="{StaticResource AccentBrush}" Visibility="Collapsed" />
|
||||
<Border BorderBrush="{StaticResource DividerBrush}" BorderThickness="1" CornerRadius="4" Height="270">
|
||||
<FlowDocumentScrollViewer x:Name="ChangelogViewer" VerticalScrollBarVisibility="Auto"
|
||||
HorizontalScrollBarVisibility="Disabled" IsToolBarVisible="False"
|
||||
Zoom="100" MinZoom="100" MaxZoom="100" Focusable="False" />
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</TabItem>
|
||||
</TabControl>
|
||||
</DockPanel>
|
||||
</Window>
|
||||
@@ -10,6 +10,7 @@ using System.Windows.Media.Imaging;
|
||||
using Kniip.Capture;
|
||||
using Kniip.Editor;
|
||||
using Kniip.Infrastructure;
|
||||
using Kniip.Update;
|
||||
using Kniip.Upload;
|
||||
|
||||
namespace Kniip.Settings;
|
||||
@@ -35,6 +36,9 @@ public partial class SettingsWindow : Window
|
||||
private string? _videoFolder;
|
||||
private UploaderConfig? _uploader;
|
||||
private bool _resetEditor;
|
||||
private bool _updateChecked;
|
||||
private ReleaseInfo? _update;
|
||||
private System.Threading.CancellationTokenSource? _updateCts;
|
||||
|
||||
public SettingsWindow(AppSettings settings)
|
||||
{
|
||||
@@ -64,8 +68,13 @@ public partial class SettingsWindow : Window
|
||||
? HotkeyHintText
|
||||
: "De toetsenbordhaak kon niet worden gestart; de sneltoetsen werken nu niet.";
|
||||
Deactivated += (_, _) => StopListening();
|
||||
Closed += (_, _) => StopListening();
|
||||
Closed += (_, _) =>
|
||||
{
|
||||
StopListening();
|
||||
_updateCts?.Cancel();
|
||||
};
|
||||
|
||||
CurrentVersionText.Text = $"Geïnstalleerde versie: {UpdateChecker.CurrentVersion}";
|
||||
LoadFrom(settings);
|
||||
|
||||
MaxHeight = SystemParameters.WorkArea.Height;
|
||||
@@ -86,6 +95,9 @@ public partial class SettingsWindow : Window
|
||||
_snipFolder = settings.SnipSaveFolder;
|
||||
_videoFolder = settings.VideoSaveFolder;
|
||||
ShowSaveFolders();
|
||||
SnipTemplateBox.Text = settings.SnipFileNameTemplate ?? string.Empty;
|
||||
VideoTemplateBox.Text = settings.VideoFileNameTemplate ?? string.Empty;
|
||||
ShowTemplatePreviews();
|
||||
SaveAndCopyRadio.IsChecked = settings.SaveButton == SaveButtonChoice.SaveAndCopy;
|
||||
SaveOnlyRadio.IsChecked = settings.SaveButton != SaveButtonChoice.SaveAndCopy;
|
||||
|
||||
@@ -233,6 +245,19 @@ public partial class SettingsWindow : Window
|
||||
text.Foreground = string.IsNullOrEmpty(folder) ? (Brush)Application.Current.Resources["SubtleTextBrush"] : SystemColors.ControlTextBrush;
|
||||
}
|
||||
|
||||
private void Template_TextChanged(object sender, TextChangedEventArgs e) => ShowTemplatePreviews();
|
||||
|
||||
private void ShowTemplatePreviews()
|
||||
{
|
||||
TemplateHelp.Text = FileNameTemplate.Help;
|
||||
SnipTemplatePreview.Text = "Voorbeeld: " + FileNameTemplate.Preview(NullIfBlank(SnipTemplateBox.Text), "Cursor",
|
||||
$"Knipsel {DateTime.Now:yyyy-MM-dd HHmmss}", ".png");
|
||||
VideoTemplatePreview.Text = "Voorbeeld: " + FileNameTemplate.Preview(NullIfBlank(VideoTemplateBox.Text), "Cursor",
|
||||
$"Screencast {DateTime.Now:yyyy-MM-dd HHmmss}", ".mp4");
|
||||
}
|
||||
|
||||
private static string? NullIfBlank(string text) => string.IsNullOrWhiteSpace(text) ? null : text.Trim();
|
||||
|
||||
private void BrowseSaveFolder_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
bool snip = sender is FrameworkElement { Tag: "Snip" };
|
||||
@@ -396,6 +421,8 @@ public partial class SettingsWindow : Window
|
||||
_settings.Uploader = _uploader;
|
||||
_settings.SnipSaveFolder = _snipFolder;
|
||||
_settings.VideoSaveFolder = _videoFolder;
|
||||
_settings.SnipFileNameTemplate = NullIfBlank(SnipTemplateBox.Text);
|
||||
_settings.VideoFileNameTemplate = NullIfBlank(VideoTemplateBox.Text);
|
||||
_settings.SaveButton = SaveAndCopyRadio.IsChecked == true
|
||||
? SaveButtonChoice.SaveAndCopy
|
||||
: SaveButtonChoice.SaveOnly;
|
||||
@@ -464,6 +491,190 @@ public partial class SettingsWindow : Window
|
||||
if (e.OriginalSource is TabControl)
|
||||
{
|
||||
StopListening();
|
||||
if (UpdateTab.IsSelected && !_updateChecked)
|
||||
{
|
||||
_ = CheckForUpdatesAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void CheckUpdate_Click(object sender, RoutedEventArgs e) => _ = CheckForUpdatesAsync();
|
||||
|
||||
private void ReleasePage_Click(object sender, RoutedEventArgs e) => OpenUrl(UpdateChecker.ReleasesPage);
|
||||
|
||||
private async System.Threading.Tasks.Task CheckForUpdatesAsync()
|
||||
{
|
||||
_updateChecked = true;
|
||||
_updateCts?.Cancel();
|
||||
var cts = new System.Threading.CancellationTokenSource();
|
||||
_updateCts = cts;
|
||||
|
||||
_update = null;
|
||||
UpdateButton.Visibility = Visibility.Collapsed;
|
||||
CheckUpdateButton.IsEnabled = false;
|
||||
UpdateStatus.Text = "Controleren op updates...";
|
||||
try
|
||||
{
|
||||
IReadOnlyList<ReleaseInfo> releases = await UpdateChecker.GetReleasesAsync(cts.Token);
|
||||
ShowReleases(releases);
|
||||
}
|
||||
catch (UpdateException ex)
|
||||
{
|
||||
UpdateStatus.Text = ex.Message;
|
||||
ShowChangelog(Array.Empty<ReleaseInfo>(), "Geen changelog beschikbaar.");
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (_updateCts == cts)
|
||||
{
|
||||
_updateCts = null;
|
||||
CheckUpdateButton.IsEnabled = true;
|
||||
}
|
||||
|
||||
cts.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private void ShowReleases(IReadOnlyList<ReleaseInfo> releases)
|
||||
{
|
||||
Version current = UpdateChecker.CurrentVersion;
|
||||
List<ReleaseInfo> newer = releases.Where(r => r.Version > current).ToList();
|
||||
if (newer.Count == 0)
|
||||
{
|
||||
UpdateStatus.Text = releases.Count == 0
|
||||
? "Er zijn nog geen releases gepubliceerd."
|
||||
: $"Je gebruikt de nieuwste versie ({current}).";
|
||||
ReleaseInfo? notes = releases.FirstOrDefault(r => r.Version == current) ?? releases.FirstOrDefault();
|
||||
ShowChangelog(notes == null ? Array.Empty<ReleaseInfo>() : new[] { notes }, "Geen changelog beschikbaar.");
|
||||
return;
|
||||
}
|
||||
|
||||
_update = newer.FirstOrDefault(r => r.InstallerUrl != null);
|
||||
if (_update != null)
|
||||
{
|
||||
UpdateStatus.Text = $"Versie {_update.Version} is beschikbaar.";
|
||||
UpdateButton.Content = $"Updaten naar {_update.Version}";
|
||||
UpdateButton.Visibility = Visibility.Visible;
|
||||
}
|
||||
else
|
||||
{
|
||||
UpdateStatus.Text = $"Versie {newer[0].Version} is beschikbaar, maar zonder installer. Download die via de releasepagina.";
|
||||
}
|
||||
|
||||
ShowChangelog(newer, null);
|
||||
}
|
||||
|
||||
private void ShowChangelog(IReadOnlyList<ReleaseInfo> releases, string? emptyText)
|
||||
{
|
||||
FlowDocument document = MarkdownRenderer.CreateDocument();
|
||||
var culture = System.Globalization.CultureInfo.GetCultureInfo("nl-NL");
|
||||
var baseUri = new Uri(UpdateChecker.RepositoryUrl);
|
||||
foreach (ReleaseInfo release in releases)
|
||||
{
|
||||
Paragraph heading = MarkdownRenderer.Heading($"Versie {release.Version}", 17,
|
||||
new Thickness(0, document.Blocks.Count == 0 ? 0 : 14, 0, 0));
|
||||
document.Blocks.Add(heading);
|
||||
if (release.Date is DateTimeOffset date)
|
||||
{
|
||||
document.Blocks.Add(new Paragraph(new Run(date.ToLocalTime().ToString("d MMMM yyyy", culture)))
|
||||
{
|
||||
Foreground = (Brush)Application.Current.Resources["SubtleTextBrush"],
|
||||
FontSize = 12,
|
||||
Margin = new Thickness(0, 0, 0, 8),
|
||||
});
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(release.Body))
|
||||
{
|
||||
document.Blocks.Add(new Paragraph(new Run("Geen beschrijving."))
|
||||
{
|
||||
Foreground = (Brush)Application.Current.Resources["SubtleTextBrush"],
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
MarkdownRenderer.Append(document, release.Body, baseUri);
|
||||
}
|
||||
}
|
||||
|
||||
if (releases.Count == 0 && emptyText != null)
|
||||
{
|
||||
document.Blocks.Add(new Paragraph(new Run(emptyText))
|
||||
{
|
||||
Foreground = (Brush)Application.Current.Resources["SubtleTextBrush"],
|
||||
});
|
||||
}
|
||||
|
||||
ChangelogViewer.Document = document;
|
||||
}
|
||||
|
||||
private async void Update_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_update is not { } release)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
string installFolder = System.IO.Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Programs", "Kniip");
|
||||
bool installed = Environment.ProcessPath is { } exe
|
||||
&& string.Equals(System.IO.Path.GetDirectoryName(exe), installFolder, StringComparison.OrdinalIgnoreCase);
|
||||
string message = $"kniip wordt afgesloten, bijgewerkt naar versie {release.Version} en daarna opnieuw gestart. " +
|
||||
"Open editors en screencasts worden eerst gesloten.";
|
||||
if (!installed)
|
||||
{
|
||||
message += $"\n\nDeze kniip is niet via de installer gestart; de nieuwe versie wordt geïnstalleerd in {installFolder}.";
|
||||
}
|
||||
|
||||
if (MessageBox.Show(this, message, "kniip bijwerken", MessageBoxButton.OKCancel, MessageBoxImage.Information) != MessageBoxResult.OK)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var cts = new System.Threading.CancellationTokenSource();
|
||||
_updateCts = cts;
|
||||
UpdateButton.IsEnabled = false;
|
||||
CheckUpdateButton.IsEnabled = false;
|
||||
DownloadProgress.Value = 0;
|
||||
DownloadProgress.Visibility = Visibility.Visible;
|
||||
UpdateStatus.Text = $"Versie {release.Version} downloaden...";
|
||||
string path;
|
||||
try
|
||||
{
|
||||
path = await UpdateInstaller.DownloadAsync(release, new Progress<double>(p => DownloadProgress.Value = p), cts.Token);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return;
|
||||
}
|
||||
catch (Exception ex) when (ex is UpdateException or System.IO.IOException or UnauthorizedAccessException)
|
||||
{
|
||||
UpdateStatus.Text = $"Versie {release.Version} is beschikbaar.";
|
||||
MessageBox.Show(this, $"De update kon niet worden gedownload.\n\n{ex.Message}", "kniip",
|
||||
MessageBoxButton.OK, MessageBoxImage.Warning);
|
||||
return;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (_updateCts == cts)
|
||||
{
|
||||
_updateCts = null;
|
||||
}
|
||||
|
||||
cts.Dispose();
|
||||
if (IsLoaded)
|
||||
{
|
||||
DownloadProgress.Visibility = Visibility.Collapsed;
|
||||
UpdateButton.IsEnabled = true;
|
||||
CheckUpdateButton.IsEnabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
UpdateStatus.Text = "Installer starten...";
|
||||
App.Instance.InstallUpdate(path);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Windows;
|
||||
using System.Windows.Documents;
|
||||
using System.Windows.Media;
|
||||
using Markdig;
|
||||
using Markdig.Extensions.TaskLists;
|
||||
using Markdig.Syntax;
|
||||
using Markdig.Syntax.Inlines;
|
||||
using MdBlock = Markdig.Syntax.Block;
|
||||
using MdInline = Markdig.Syntax.Inlines.Inline;
|
||||
using WpfBlock = System.Windows.Documents.Block;
|
||||
using WpfInline = System.Windows.Documents.Inline;
|
||||
using WpfList = System.Windows.Documents.List;
|
||||
|
||||
namespace Kniip.Update;
|
||||
|
||||
/// <summary>Zet markdown (releasenotes) om naar een WPF <see cref="FlowDocument"/>.</summary>
|
||||
public static class MarkdownRenderer
|
||||
{
|
||||
private static readonly MarkdownPipeline Pipeline = new MarkdownPipelineBuilder()
|
||||
.UseEmphasisExtras()
|
||||
.UseAutoLinks()
|
||||
.UseTaskLists()
|
||||
.Build();
|
||||
|
||||
private static readonly FontFamily CodeFont = new("Cascadia Mono, Consolas");
|
||||
private static readonly Brush CodeBackground = Freeze(new SolidColorBrush(Color.FromRgb(0xF3, 0xF3, 0xF3)));
|
||||
private static readonly Brush QuoteBorder = Freeze(new SolidColorBrush(Color.FromRgb(0xD0, 0xD0, 0xD0)));
|
||||
|
||||
private static Brush Accent => Resource("AccentBrush", Brushes.SteelBlue);
|
||||
|
||||
private static Brush Subtle => Resource("SubtleTextBrush", Brushes.Gray);
|
||||
|
||||
public static FlowDocument CreateDocument() => new()
|
||||
{
|
||||
FontFamily = new FontFamily("Segoe UI"),
|
||||
FontSize = 13,
|
||||
PagePadding = new Thickness(14, 10, 14, 10),
|
||||
TextAlignment = TextAlignment.Left,
|
||||
Background = Brushes.White,
|
||||
};
|
||||
|
||||
/// <summary>Voegt de blokken van <paramref name="markdown"/> toe; relatieve links wijzen naar <paramref name="baseUri"/>.</summary>
|
||||
public static void Append(FlowDocument document, string markdown, Uri? baseUri = null)
|
||||
{
|
||||
MarkdownDocument parsed = Markdown.Parse(markdown ?? string.Empty, Pipeline);
|
||||
foreach (MdBlock block in parsed)
|
||||
{
|
||||
if (RenderBlock(block, baseUri) is { } rendered)
|
||||
{
|
||||
document.Blocks.Add(rendered);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static Paragraph Heading(string text, double fontSize, Thickness margin) => new(new Run(text))
|
||||
{
|
||||
FontSize = fontSize,
|
||||
FontWeight = FontWeights.SemiBold,
|
||||
Margin = margin,
|
||||
};
|
||||
|
||||
private static WpfBlock? RenderBlock(MdBlock block, Uri? baseUri)
|
||||
{
|
||||
switch (block)
|
||||
{
|
||||
case HeadingBlock heading:
|
||||
{
|
||||
var paragraph = new Paragraph
|
||||
{
|
||||
FontSize = heading.Level switch { 1 => 19, 2 => 16.5, 3 => 14.5, _ => 13 },
|
||||
FontWeight = FontWeights.SemiBold,
|
||||
Margin = new Thickness(0, heading.Level <= 2 ? 12 : 8, 0, 4),
|
||||
};
|
||||
AddInlines(paragraph.Inlines, heading.Inline, baseUri);
|
||||
return paragraph;
|
||||
}
|
||||
|
||||
case ParagraphBlock paragraphBlock:
|
||||
{
|
||||
var paragraph = new Paragraph { Margin = new Thickness(0, 0, 0, 8) };
|
||||
AddInlines(paragraph.Inlines, paragraphBlock.Inline, baseUri);
|
||||
return paragraph;
|
||||
}
|
||||
|
||||
case ListBlock listBlock:
|
||||
{
|
||||
var list = new WpfList
|
||||
{
|
||||
MarkerStyle = listBlock.IsOrdered ? TextMarkerStyle.Decimal : TextMarkerStyle.Disc,
|
||||
Margin = new Thickness(0, 0, 0, 8),
|
||||
Padding = new Thickness(20, 0, 0, 0),
|
||||
};
|
||||
if (listBlock.IsOrdered && int.TryParse(listBlock.OrderedStart, out int start) && start > 0)
|
||||
{
|
||||
list.StartIndex = start;
|
||||
}
|
||||
|
||||
foreach (ListItemBlock itemBlock in listBlock.OfType<ListItemBlock>())
|
||||
{
|
||||
var item = new ListItem();
|
||||
foreach (MdBlock child in itemBlock)
|
||||
{
|
||||
if (RenderBlock(child, baseUri) is { } rendered)
|
||||
{
|
||||
rendered.Margin = new Thickness(0, 0, 0, 2);
|
||||
item.Blocks.Add(rendered);
|
||||
}
|
||||
}
|
||||
|
||||
list.ListItems.Add(item);
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
case QuoteBlock quote:
|
||||
{
|
||||
var section = new Section
|
||||
{
|
||||
BorderBrush = QuoteBorder,
|
||||
BorderThickness = new Thickness(3, 0, 0, 0),
|
||||
Padding = new Thickness(10, 0, 0, 0),
|
||||
Margin = new Thickness(0, 0, 0, 8),
|
||||
Foreground = Subtle,
|
||||
};
|
||||
foreach (MdBlock child in quote)
|
||||
{
|
||||
if (RenderBlock(child, baseUri) is { } rendered)
|
||||
{
|
||||
section.Blocks.Add(rendered);
|
||||
}
|
||||
}
|
||||
|
||||
return section;
|
||||
}
|
||||
|
||||
case CodeBlock code:
|
||||
{
|
||||
var paragraph = new Paragraph
|
||||
{
|
||||
FontFamily = CodeFont,
|
||||
FontSize = 12,
|
||||
Background = CodeBackground,
|
||||
Padding = new Thickness(8, 6, 8, 6),
|
||||
Margin = new Thickness(0, 0, 0, 8),
|
||||
};
|
||||
string[] lines = code.Lines.ToString().Replace("\r\n", "\n").TrimEnd('\n').Split('\n');
|
||||
for (int i = 0; i < lines.Length; i++)
|
||||
{
|
||||
if (i > 0)
|
||||
{
|
||||
paragraph.Inlines.Add(new LineBreak());
|
||||
}
|
||||
|
||||
paragraph.Inlines.Add(new Run(lines[i]));
|
||||
}
|
||||
|
||||
return paragraph;
|
||||
}
|
||||
|
||||
case ThematicBreakBlock:
|
||||
return new Paragraph
|
||||
{
|
||||
BorderBrush = QuoteBorder,
|
||||
BorderThickness = new Thickness(0, 0, 0, 1),
|
||||
FontSize = 1,
|
||||
Margin = new Thickness(0, 4, 0, 10),
|
||||
};
|
||||
|
||||
case HtmlBlock:
|
||||
return null;
|
||||
|
||||
case ContainerBlock container:
|
||||
{
|
||||
var section = new Section();
|
||||
foreach (MdBlock child in container)
|
||||
{
|
||||
if (RenderBlock(child, baseUri) is { } rendered)
|
||||
{
|
||||
section.Blocks.Add(rendered);
|
||||
}
|
||||
}
|
||||
|
||||
return section.Blocks.Count > 0 ? section : null;
|
||||
}
|
||||
|
||||
case LeafBlock leaf when leaf.Inline != null:
|
||||
{
|
||||
var paragraph = new Paragraph { Margin = new Thickness(0, 0, 0, 8) };
|
||||
AddInlines(paragraph.Inlines, leaf.Inline, baseUri);
|
||||
return paragraph;
|
||||
}
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddInlines(InlineCollection target, ContainerInline? container, Uri? baseUri)
|
||||
{
|
||||
if (container == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (MdInline inline in container)
|
||||
{
|
||||
if (RenderInline(inline, baseUri) is { } rendered)
|
||||
{
|
||||
target.Add(rendered);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static WpfInline? RenderInline(MdInline inline, Uri? baseUri)
|
||||
{
|
||||
switch (inline)
|
||||
{
|
||||
case LiteralInline literal:
|
||||
return new Run(literal.Content.ToString());
|
||||
|
||||
case CodeInline code:
|
||||
return new Run(code.Content)
|
||||
{
|
||||
FontFamily = CodeFont,
|
||||
FontSize = 12,
|
||||
Background = CodeBackground,
|
||||
};
|
||||
|
||||
case LineBreakInline lineBreak:
|
||||
return lineBreak.IsHard ? new LineBreak() : new Run(" ");
|
||||
|
||||
case TaskList task:
|
||||
return new Run(task.Checked ? "\u2611 " : "\u2610 ");
|
||||
|
||||
case AutolinkInline autolink:
|
||||
return CreateLink(new Span(new Run(autolink.Url)), autolink.IsEmail ? "mailto:" + autolink.Url : autolink.Url, baseUri);
|
||||
|
||||
case LinkInline link:
|
||||
{
|
||||
var span = new Span();
|
||||
AddInlines(span.Inlines, link, baseUri);
|
||||
if (span.Inlines.Count == 0)
|
||||
{
|
||||
span.Inlines.Add(new Run(link.Url ?? string.Empty));
|
||||
}
|
||||
|
||||
return CreateLink(span, link.Url, baseUri);
|
||||
}
|
||||
|
||||
case EmphasisInline emphasis:
|
||||
{
|
||||
var span = new Span();
|
||||
AddInlines(span.Inlines, emphasis, baseUri);
|
||||
if (emphasis.DelimiterChar == '~')
|
||||
{
|
||||
span.TextDecorations = TextDecorations.Strikethrough;
|
||||
}
|
||||
else if (emphasis.DelimiterCount >= 2)
|
||||
{
|
||||
span.FontWeight = FontWeights.SemiBold;
|
||||
}
|
||||
else
|
||||
{
|
||||
span.FontStyle = FontStyles.Italic;
|
||||
}
|
||||
|
||||
return span;
|
||||
}
|
||||
|
||||
case HtmlInline html when html.Tag.StartsWith("<br", StringComparison.OrdinalIgnoreCase):
|
||||
return new LineBreak();
|
||||
|
||||
case HtmlEntityInline entity:
|
||||
return new Run(entity.Transcoded.ToString());
|
||||
|
||||
case HtmlInline:
|
||||
return null;
|
||||
|
||||
case ContainerInline container:
|
||||
{
|
||||
var span = new Span();
|
||||
AddInlines(span.Inlines, container, baseUri);
|
||||
return span;
|
||||
}
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static WpfInline CreateLink(Span content, string? url, Uri? baseUri)
|
||||
{
|
||||
Uri? target = ResolveUrl(url, baseUri);
|
||||
if (target == null)
|
||||
{
|
||||
return content;
|
||||
}
|
||||
|
||||
var link = new Hyperlink(content)
|
||||
{
|
||||
Foreground = Accent,
|
||||
ToolTip = target.ToString(),
|
||||
NavigateUri = target,
|
||||
};
|
||||
link.RequestNavigate += (_, e) =>
|
||||
{
|
||||
OpenUrl(e.Uri);
|
||||
e.Handled = true;
|
||||
};
|
||||
return link;
|
||||
}
|
||||
|
||||
private static Uri? ResolveUrl(string? url, Uri? baseUri)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(url))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
Uri? uri = Uri.TryCreate(url, UriKind.Absolute, out Uri? absolute) ? absolute
|
||||
: baseUri != null && Uri.TryCreate(baseUri, url, out Uri? relative) ? relative
|
||||
: null;
|
||||
return uri != null && (uri.Scheme == Uri.UriSchemeHttps || uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeMailto)
|
||||
? uri
|
||||
: null;
|
||||
}
|
||||
|
||||
public static void OpenUrl(Uri uri)
|
||||
{
|
||||
try
|
||||
{
|
||||
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(uri.AbsoluteUri) { UseShellExecute = true });
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Zonder standaardbrowser gebeurt er niets; de link staat in de tooltip.
|
||||
}
|
||||
}
|
||||
|
||||
private static Brush Resource(string key, Brush fallback) =>
|
||||
Application.Current?.TryFindResource(key) as Brush ?? fallback;
|
||||
|
||||
private static Brush Freeze(SolidColorBrush brush)
|
||||
{
|
||||
brush.Freeze();
|
||||
return brush;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Reflection;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Kniip.Update;
|
||||
|
||||
public sealed class UpdateException : Exception
|
||||
{
|
||||
public UpdateException(string message, Exception? inner = null) : base(message, inner)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record ReleaseInfo(
|
||||
Version Version,
|
||||
string Tag,
|
||||
string Title,
|
||||
DateTimeOffset? Date,
|
||||
string Body,
|
||||
string HtmlUrl,
|
||||
string? InstallerUrl,
|
||||
long InstallerSize);
|
||||
|
||||
/// <summary>Leest de releases van kniip uit de Gitea/Forgejo-API van git.hibbit.net.</summary>
|
||||
public static class UpdateChecker
|
||||
{
|
||||
public const string RepositoryUrl = "https://git.hibbit.net/rogier/kniip/";
|
||||
public const string ReleasesPage = "https://git.hibbit.net/rogier/kniip/releases";
|
||||
private const string ReleasesApi = "https://git.hibbit.net/api/v1/repos/rogier/kniip/releases?limit=20";
|
||||
|
||||
internal static readonly HttpClient Client = new() { Timeout = TimeSpan.FromMinutes(10) };
|
||||
|
||||
static UpdateChecker()
|
||||
{
|
||||
Client.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("kniip", CurrentVersion.ToString()));
|
||||
}
|
||||
|
||||
/// <summary>Versie van deze exe, altijd in drie delen (1.2.0).</summary>
|
||||
public static Version CurrentVersion { get; } = Normalize(Assembly.GetEntryAssembly()?.GetName().Version ?? new Version(1, 0, 0));
|
||||
|
||||
/// <summary>Gepubliceerde releases, nieuwste eerst; concepten en voorversies vallen af.</summary>
|
||||
public static async Task<IReadOnlyList<ReleaseInfo>> GetReleasesAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
string body;
|
||||
try
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, ReleasesApi);
|
||||
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
||||
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
timeout.CancelAfter(TimeSpan.FromSeconds(20));
|
||||
using HttpResponseMessage response = await Client.SendAsync(request, timeout.Token).ConfigureAwait(false);
|
||||
if (response.StatusCode == HttpStatusCode.NotFound)
|
||||
{
|
||||
throw new UpdateException("De releases van kniip zijn niet gevonden op git.hibbit.net.");
|
||||
}
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
throw new UpdateException($"git.hibbit.net gaf een fout ({(int)response.StatusCode}).");
|
||||
}
|
||||
|
||||
body = await response.Content.ReadAsStringAsync(timeout.Token).ConfigureAwait(false);
|
||||
}
|
||||
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw new UpdateException("git.hibbit.net reageert niet (time-out).", ex);
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
throw new UpdateException("Kan git.hibbit.net niet bereiken. Controleer de internetverbinding.", ex);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return Parse(body);
|
||||
}
|
||||
catch (Exception ex) when (ex is JsonException or InvalidOperationException or KeyNotFoundException)
|
||||
{
|
||||
throw new UpdateException("Het antwoord van git.hibbit.net is niet te lezen.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private static List<ReleaseInfo> Parse(string json)
|
||||
{
|
||||
using JsonDocument document = JsonDocument.Parse(json);
|
||||
var releases = new List<ReleaseInfo>();
|
||||
foreach (JsonElement item in document.RootElement.EnumerateArray())
|
||||
{
|
||||
if (Bool(item, "draft") || Bool(item, "prerelease") || !TryParseVersion(String(item, "tag_name"), out Version version))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
string? installerUrl = null;
|
||||
long installerSize = 0;
|
||||
if (item.TryGetProperty("assets", out JsonElement assets) && assets.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (JsonElement asset in assets.EnumerateArray())
|
||||
{
|
||||
string name = String(asset, "name");
|
||||
if (name.StartsWith("Kniip-Setup-", StringComparison.OrdinalIgnoreCase)
|
||||
&& name.EndsWith(".exe", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
installerUrl = String(asset, "browser_download_url");
|
||||
installerSize = asset.TryGetProperty("size", out JsonElement size) && size.TryGetInt64(out long bytes) ? bytes : 0;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DateTimeOffset? date = DateTimeOffset.TryParse(String(item, "published_at"), CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.None, out DateTimeOffset published) ? published : null;
|
||||
string tag = String(item, "tag_name");
|
||||
string title = String(item, "name");
|
||||
releases.Add(new ReleaseInfo(version, tag, title.Length > 0 ? title : tag, date, String(item, "body"),
|
||||
String(item, "html_url") is { Length: > 0 } url ? url : ReleasesPage,
|
||||
string.IsNullOrEmpty(installerUrl) ? null : installerUrl, installerSize));
|
||||
}
|
||||
|
||||
return releases.OrderByDescending(r => r.Version).ToList();
|
||||
}
|
||||
|
||||
internal static bool TryParseVersion(string? tag, out Version version)
|
||||
{
|
||||
version = new Version(0, 0, 0);
|
||||
string text = (tag ?? string.Empty).Trim().TrimStart('v', 'V');
|
||||
int suffix = text.IndexOfAny(new[] { '-', '+' });
|
||||
if (suffix >= 0)
|
||||
{
|
||||
text = text[..suffix];
|
||||
}
|
||||
|
||||
if (!Version.TryParse(text.Contains('.') ? text : text + ".0", out Version? parsed))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
version = Normalize(parsed);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static Version Normalize(Version version) =>
|
||||
new(version.Major, Math.Max(version.Minor, 0), Math.Max(version.Build, 0));
|
||||
|
||||
private static string String(JsonElement element, string name) =>
|
||||
element.TryGetProperty(name, out JsonElement value) && value.ValueKind == JsonValueKind.String ? value.GetString() ?? string.Empty : string.Empty;
|
||||
|
||||
private static bool Bool(JsonElement element, string name) =>
|
||||
element.TryGetProperty(name, out JsonElement value) && value.ValueKind == JsonValueKind.True;
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Kniip.Update;
|
||||
|
||||
/// <summary>Downloadt de installer van een release en start die stil.</summary>
|
||||
public static class UpdateInstaller
|
||||
{
|
||||
public static string DownloadFolder => Path.Combine(Path.GetTempPath(), "kniip-update");
|
||||
|
||||
public static async Task<string> DownloadAsync(ReleaseInfo release, IProgress<double>? progress = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (release.InstallerUrl is not { } url)
|
||||
{
|
||||
throw new UpdateException($"Release {release.Tag} heeft geen installer.");
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(DownloadFolder);
|
||||
string path = Path.Combine(DownloadFolder, $"Kniip-Setup-{release.Version}.exe");
|
||||
string partial = path + ".download";
|
||||
try
|
||||
{
|
||||
using (HttpResponseMessage response = await UpdateChecker.Client
|
||||
.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
throw new UpdateException($"Downloaden is mislukt ({(int)response.StatusCode}).");
|
||||
}
|
||||
|
||||
long total = response.Content.Headers.ContentLength ?? release.InstallerSize;
|
||||
await using Stream source = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var target = new FileStream(partial, FileMode.Create, FileAccess.Write, FileShare.None, 81920, useAsync: true);
|
||||
var buffer = new byte[81920];
|
||||
long received = 0;
|
||||
int read;
|
||||
while ((read = await source.ReadAsync(buffer, cancellationToken).ConfigureAwait(false)) > 0)
|
||||
{
|
||||
await target.WriteAsync(buffer.AsMemory(0, read), cancellationToken).ConfigureAwait(false);
|
||||
received += read;
|
||||
if (total > 0)
|
||||
{
|
||||
progress?.Report(Math.Min(1, (double)received / total));
|
||||
}
|
||||
}
|
||||
|
||||
if (release.InstallerSize > 0 && received != release.InstallerSize)
|
||||
{
|
||||
throw new UpdateException("De download is onvolledig; probeer het opnieuw.");
|
||||
}
|
||||
}
|
||||
|
||||
File.Move(partial, path, overwrite: true);
|
||||
return path;
|
||||
}
|
||||
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw new UpdateException("git.hibbit.net reageert niet (time-out).", ex);
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
throw new UpdateException("Downloaden is mislukt. Controleer de internetverbinding.", ex);
|
||||
}
|
||||
finally
|
||||
{
|
||||
TryDelete(partial);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Start de installer zonder vensters; die sluit kniip en start het daarna opnieuw.</summary>
|
||||
public static void Start(string installerPath) =>
|
||||
Process.Start(new ProcessStartInfo(installerPath)
|
||||
{
|
||||
Arguments = "/VERYSILENT /SUPPRESSMSGBOXES /NORESTART /CLOSEAPPLICATIONS",
|
||||
UseShellExecute = true,
|
||||
WorkingDirectory = Path.GetDirectoryName(installerPath) ?? string.Empty,
|
||||
});
|
||||
|
||||
private static void TryDelete(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(path))
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user