This commit is contained in:
rogier committed 2026-11-09 00:07:44 +01:00
1 parent e7ed05922e
commit cc08b00e3b
20 files changed
+1318 -32

No files matched your search

+48 -8
View File
@@ -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));
}
+8 -4
View File
@@ -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
+2 -1
View File
@@ -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>
+35 -3
View File
@@ -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);
+13 -4
View File
@@ -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;
+11 -6
View File
@@ -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
};
+6
View File
@@ -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;
+117
View File
@@ -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();
}
}
+37 -1
View File
@@ -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>
+212 -1
View File
@@ -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);
}
}
+351
View File
@@ -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;
}
}
+158
View File
@@ -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;
}
+100
View File
@@ -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)
{
}
}
}