From cc08b00e3b4a30e6eb9c1457c96fc5ca231e797d Mon Sep 17 00:00:00 2001 From: R0GGER Date: Mon, 9 Nov 2026 00:07:44 +0100 Subject: [PATCH] 1.1.0 --- .../naamsjabloon_opslaan_188581d6.plan.md | 64 ++++ .../update-tab_met_changelog_6879da2a.plan.md | 86 +++++ CHANGELOG.md | 42 +++ README.md | 17 +- build.ps1 | 7 +- installer/Kniip.iss | 4 +- src/Kniip/App.xaml.cs | 56 ++- src/Kniip/Capture/RegionCaptureWindow.xaml.cs | 4 + src/Kniip/Editor/EditorWindow.xaml.cs | 12 +- src/Kniip/Kniip.csproj | 3 +- src/Kniip/Recording/PhysicalWindow.cs | 38 +- src/Kniip/Recording/RecordingController.cs | 17 +- src/Kniip/Recording/ScreencastWindow.xaml.cs | 17 +- src/Kniip/Settings/AppSettings.cs | 6 + src/Kniip/Settings/FileNameTemplate.cs | 117 ++++++ src/Kniip/Settings/SettingsWindow.xaml | 38 +- src/Kniip/Settings/SettingsWindow.xaml.cs | 213 ++++++++++- src/Kniip/Update/MarkdownRenderer.cs | 351 ++++++++++++++++++ src/Kniip/Update/UpdateChecker.cs | 158 ++++++++ src/Kniip/Update/UpdateInstaller.cs | 100 +++++ 20 files changed, 1318 insertions(+), 32 deletions(-) create mode 100644 .cursor/plans/naamsjabloon_opslaan_188581d6.plan.md create mode 100644 .cursor/plans/update-tab_met_changelog_6879da2a.plan.md create mode 100644 CHANGELOG.md create mode 100644 src/Kniip/Settings/FileNameTemplate.cs create mode 100644 src/Kniip/Update/MarkdownRenderer.cs create mode 100644 src/Kniip/Update/UpdateChecker.cs create mode 100644 src/Kniip/Update/UpdateInstaller.cs diff --git a/.cursor/plans/naamsjabloon_opslaan_188581d6.plan.md b/.cursor/plans/naamsjabloon_opslaan_188581d6.plan.md new file mode 100644 index 0000000..415da12 --- /dev/null +++ b/.cursor/plans/naamsjabloon_opslaan_188581d6.plan.md @@ -0,0 +1,64 @@ +--- +name: Naamsjabloon opslaan +overview: In de tab Opslaan komt per soort (knipsels en screencasts) een eigen naamsjabloon met %J, %M, %D, %V, %Rn en %Nn. Mappen worden automatisch aangemaakt en kniip plakt zelf de extensie (.png, .mp4 of .gif) erachter. +todos: + - id: template + content: FileNameTemplate.cs maken (tokens, submappen, opschonen, %N per map, fallback) + status: completed + - id: settings + content: SnipFileNameTemplate en VideoFileNameTemplate toevoegen aan AppSettings + status: completed + - id: windowname + content: Programmanaam van het venster bepalen in SnapshotWindows en doorgeven via RegionCaptureWindow, App, RecordingController + status: completed + - id: save + content: EditorWindow.Save/SaveAs en ScreencastWindow.FixedTarget/DefaultFileName op het sjabloon laten draaien + status: completed + - id: ui + content: Naamvelden met live voorbeeld en tokenuitleg toevoegen in de tab Opslaan + status: completed + - id: build + content: Bouwen en testen met het voorbeeld %J/%M-%D/%V_%N3 + status: completed +isProject: false +--- + +# Naamsjabloon voor opslaan + +## Gedrag + +- In de tab **Opslaan** komt onder elke map (Knipsels en Screencasts) een veld **Naam**, met een live voorbeeld eronder. Bijvoorbeeld: `%J/%M-%D/%V_%N3` wordt `2026/11-08/Cursor_001.png`. +- Tokens: + - `%J` is het jaar (2026). + - `%M` is de maand en `%D` de dag, allebei met twee cijfers (11, 08), zodat de mappen goed sorteren. + - `%V` is de programmanaam van het venster, bijvoorbeeld `Cursor` of `chrome`. + - `%Rn` zijn n willekeurige letters en cijfers. + - `%Nn` is het eerste vrije nummer in de doelmap, met n cijfers (001, 002, ...). In een nieuwe map begint de teller weer bij 001. +- Met `/` of `\` maak je submappen. Die maakt kniip zelf aan, onder de ingestelde map. +- kniip plakt de extensie er zelf achter: `.png` voor knipsels, `.mp4` of `.gif` voor screencasts. +- Is het veld leeg, dan blijft de huidige naam staan (`Knipsel 2026-11-08 234500`), zodat bestaande instellingen gewoon blijven werken. +- Ongeldige tekens zoals `: * ? " < > |` worden `_`. Zit er geen `%N` in en bestaat het bestand al, dan valt kniip terug op de bestaande `(2)`-nummering uit `SaveLocation.UniquePath`. +- Tokens beginnen altijd met `%`. In jouw voorbeeld moet `_N3` dus `_%N3` zijn. Het live voorbeeld laat meteen zien of het klopt. + +## Waar komt %V vandaan + +```mermaid +flowchart LR + WinSnip["Alt+PrintScreen: aangeklikt venster"] --> Name[Programmanaam] + RegionSnip["Rechthoekknipsel: venster onder het midden van de selectie"] --> Name + Screencast["Screencast: venster onder het midden van het opnamegebied"] --> Name + Other["Knipprogramma, klembord, Openen met"] --> Fallback["'Knipsel' of 'Screencast'"] +``` + +Het venster wordt bepaald uit de vensterlijst die al vóór de overlay wordt vastgelegd (`PhysicalWindow.SnapshotWindows`). Zo staat kniip's eigen overlay er nooit tussen. + +## Wijzigingen + +- **Nieuw** [src/Kniip/Settings/FileNameTemplate.cs](src/Kniip/Settings/FileNameTemplate.cs): `Resolve(folder, template, windowName, extension)` geeft het volledige pad terug, inclusief submappen, opschoning en `%N`-telling. Daarnaast `Preview(...)` voor het voorbeeld in de instellingen. +- [src/Kniip/Settings/AppSettings.cs](src/Kniip/Settings/AppSettings.cs): twee nieuwe instellingen, `SnipFileNameTemplate` en `VideoFileNameTemplate`. Leeg betekent de huidige standaardnaam. +- [src/Kniip/Recording/PhysicalWindow.cs](src/Kniip/Recording/PhysicalWindow.cs): `SnapshotWindows` geeft naast de rechthoek ook de handle van elk venster terug. Een nieuwe helper zoekt via `GetWindowThreadProcessId` en `Process.ProcessName` de programmanaam op. +- [src/Kniip/Capture/RegionCaptureWindow.xaml.cs](src/Kniip/Capture/RegionCaptureWindow.xaml.cs) en [src/Kniip/App.xaml.cs](src/Kniip/App.xaml.cs): `Finished` levert naast de afbeelding ook de vensternaam. `OpenEditor(image, windowName)` geeft die door aan `EditorWindow`. +- [src/Kniip/Recording/RecordingController.cs](src/Kniip/Recording/RecordingController.cs): bij het kiezen van het gebied wordt de vensternaam onthouden. Die gaat via `RecordingFinished` naar `ScreencastWindow`. +- [src/Kniip/Editor/EditorWindow.xaml.cs](src/Kniip/Editor/EditorWindow.xaml.cs): `Save()` gebruikt `FileNameTemplate.Resolve` in plaats van de vaste `Knipsel {DateTime.Now:...}.png`. `SaveAs()` stelt de gegenereerde bestandsnaam voor. +- [src/Kniip/Recording/ScreencastWindow.xaml.cs](src/Kniip/Recording/ScreencastWindow.xaml.cs): `FixedTarget()` en `DefaultFileName` gaan via het sjabloon, met `.gif` of `.mp4` afhankelijk van het formaat. +- [src/Kniip/Settings/SettingsWindow.xaml](src/Kniip/Settings/SettingsWindow.xaml) en [.xaml.cs](src/Kniip/Settings/SettingsWindow.xaml.cs): per soort een `TextBox` **Naam** met een voorbeeldregel, een uitleg van de tokens als hint, en opslaan en herstellen samen met de mapinstellingen. De bestaande hint over "datum en tijd in de naam" wordt aangepast. \ No newline at end of file diff --git a/.cursor/plans/update-tab_met_changelog_6879da2a.plan.md b/.cursor/plans/update-tab_met_changelog_6879da2a.plan.md new file mode 100644 index 0000000..b427aa0 --- /dev/null +++ b/.cursor/plans/update-tab_met_changelog_6879da2a.plan.md @@ -0,0 +1,86 @@ +--- +name: Update-tab met changelog +overview: Add an "Update" tab to the settings that reads releases from git.hibbit.net (public Gitea/Forgejo API), shows the changelog rendered as Markdown, and on "Updaten" downloads the installer, runs it silently and restarts kniip. +todos: + - id: checker + content: "UpdateChecker: releases ophalen via Gitea-API, versies vergelijken" + status: completed + - id: markdown + content: Markdig-referentie + MarkdownRenderer naar FlowDocument + status: completed + - id: installer-dl + content: "UpdateInstaller: download met voortgang en stille start" + status: completed + - id: tab-ui + content: Update-tab in SettingsWindow (xaml + code-behind) + status: completed + - id: app-exit + content: "App: TryCloseAllWindows en --updated-argument" + status: completed + - id: iss + content: "Kniip.iss: herstart na stille installatie" + status: completed + - id: readme + content: "README: releasenotes en publiek-vereiste documenteren" + status: completed +isProject: false +--- + +# Update-tab met changelog + +## Uitgangspunten +- git.hibbit.net heeft een Gitea/Forgejo-API (`/api/v1/version` geeft `28.3.0`). Releases: `GET https://git.hibbit.net/api/v1/repos/rogier/kniip/releases?limit=20`. De repo moet **publiek** worden; nu geeft die URL 404. +- Per release gebruiken we deze velden: `tag_name` (`v1.2.0`), `name`, `body` (markdown-changelog), `published_at`, `draft`, `prerelease`, `html_url` en `assets[]`. Uit `assets[]` zoeken we `name` = `Kniip-Setup-*.exe` en de bijbehorende `browser_download_url` op. +- Alleen handmatig controleren: bij het openen van de tab en met de knop "Opnieuw controleren". Er komt geen achtergrondcontrole. +- Huidige versie: `Assembly.GetEntryAssembly().GetName().Version`, gezet via `-p:Version` in [build.ps1](build.ps1). Niet `InformationalVersion`, want daar zet de .NET 8 SDK een `+commithash` achter. + +```mermaid +flowchart LR + Tab[Update-tab geopend] --> Api["GET releases API"] + Api --> Compare{"Nieuwere versie?"} + Compare -->|ja| Show["Changelog van alle nieuwere versies + knop Updaten"] + Compare -->|nee| Current["'Je hebt de nieuwste versie' + changelog huidige versie"] + Show --> Download["Installer downloaden naar TEMP met voortgang"] + Download --> Exit["Vensters sluiten, installer /VERYSILENT starten, kniip afsluiten"] + Exit --> Restart["Installer start kniip --updated"] +``` + +## Nieuwe bestanden +- `src/Kniip/Update/UpdateChecker.cs` + - Record `ReleaseInfo(Version Version, string Tag, string Title, DateTimeOffset? Date, string Body, string HtmlUrl, string? InstallerUrl, long InstallerSize)`. + - `Task> GetReleasesAsync(CancellationToken)`: haalt de releases op via één statische `HttpClient` (zoals in [ZiplineUploader.cs](src/Kniip/Upload/ZiplineUploader.cs)) met User-Agent `kniip/`. Drafts, prereleases en tags die niet als versie te lezen zijn vallen af. Gesorteerd van nieuw naar oud. + - `static Version CurrentVersion`, genormaliseerd naar 3 delen voor de vergelijking. +- `src/Kniip/Update/UpdateInstaller.cs` + - `Task DownloadAsync(ReleaseInfo, IProgress, CancellationToken)` downloadt naar `%TEMP%\kniip-update\Kniip-Setup-.exe`. Daarna controleren we of de bestandsgrootte gelijk is aan `size` van de asset. + - `Start(string path)` start de installer met `/VERYSILENT /SUPPRESSMSGBOXES /NORESTART /CLOSEAPPLICATIONS`. `UsePreviousTasks` behoudt de eerder gekozen integraties. +- `src/Kniip/Update/MarkdownRenderer.cs`: zet markdown om naar een WPF `FlowDocument` via **Markdig** (nieuwe NuGet-referentie in [Kniip.csproj](src/Kniip/Kniip.csproj)). Een eigen walker over de AST ondersteunt: + - koppen, alinea's, opsommingen (ook genummerd en genest) en citaten + - codeblokken en inline code (Consolas, grijze achtergrond), vet, cursief en doorhalen + - links: `Hyperlink` die de browser opent, met `Process.Start` zoals `OpenUrl` in `SettingsWindow` + - horizontale lijnen + - kleuren en lettertypen uit de bestaande resources (`AccentBrush`, `SubtleTextBrush`, Segoe UI) + +## Wijzigingen +- [src/Kniip/Settings/SettingsWindow.xaml](src/Kniip/Settings/SettingsWindow.xaml): nieuwe `TabItem Header="Update"` na "Editor", met: + - `CardTitle` "Update", met daaronder "Geïnstalleerde versie: 1.0.0" en een statusregel (`Hint`). + - Knoppen `AccentButton` "Updaten naar x.y.z" (verborgen als er geen update is), `SecondaryButton` "Opnieuw controleren" en `FlatButton` "Releasepagina". + - `ProgressBar` (verborgen) voor de download. + - Changelog in een `FlowDocumentScrollViewer` met vaste hoogte (~300) en een rand. De tab staat al in een `ScrollViewer`, dus de viewer heeft een vaste hoogte nodig. +- [src/Kniip/Settings/SettingsWindow.xaml.cs](src/Kniip/Settings/SettingsWindow.xaml.cs): + - In `Tabs_SelectionChanged` start de eerste keer dat de Update-tab wordt gekozen de controle (`CheckForUpdatesAsync`). Het resultaat blijft bewaard zolang het venster open is. + - Changelog: per nieuwere release een kop "Versie x.y.z, d MMMM yyyy" met de body eronder. Is er geen update, dan tonen we de notes van de huidige versie. Is er een fout of offline, dan tonen we een melding in de statusregel. + - `Update_Click`: + 1. Bevestigen met "kniip wordt afgesloten en bijgewerkt". + 2. Downloaden met voortgang. Tijdens het downloaden zijn de knoppen uitgeschakeld en annuleert sluiten van het venster de download. + 3. `App.Instance.TryExitForUpdate()`. Lukt dat, dan `UpdateInstaller.Start` en `Shutdown`. +- [src/Kniip/App.xaml.cs](src/Kniip/App.xaml.cs): + - Splits `ExitApplication` op in `bool TryCloseAllWindows()`. Die stopt als er een screencast loopt of als een editor het sluiten tegenhoudt, zodat we de installer pas starten als kniip echt kan stoppen. + - Nieuw argument `--updated` in `HandleArguments`: toont de ballon "kniip is bijgewerkt naar versie x.y.z" en gedraagt zich verder als `--tray`. +- [installer/Kniip.iss](installer/Kniip.iss): extra `[Run]`-regel die kniip opnieuw start na een stille installatie: + `Filename: "{app}\{#AppExe}"; Parameters: "--updated"; Flags: nowait; Check: WizardSilent` +- [README.md](README.md), sectie "Een nieuwe versie publiceren": de releasebeschrijving (markdown) wordt de changelog in kniip, de installerbijlage moet `Kniip-Setup-.exe` heten, en de repo moet publiek zijn voor de updatecontrole. + +## Randgevallen +- Draai je kniip als losse exe (niet geïnstalleerd), dan installeert de update in `%LOCALAPPDATA%\Programs\Kniip`. Dat is acceptabel; het staat in de bevestiging. +- Een release zonder installerbijlage: geen knop Updaten, wel een link naar de releasepagina. +- Een oude download in `%TEMP%\kniip-update` wordt overschreven. \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..92e45c5 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,42 @@ +# Changelog + +Alle noemenswaardige wijzigingen in kniip staan in dit bestand. De nieuwste versie staat bovenaan. + +## 1.1.0 - 2026-11-09 + +### Nieuw + +- **Updaten vanuit kniip.** In de instellingen staat een nieuwe tab **Update**. kniip kijkt op git.hibbit.net of er een nieuwere versie is en toont de wijzigingen van alle nieuwere versies, netjes opgemaakt. Met **Updaten** downloadt kniip de installer, sluit zichzelf af, installeert de nieuwe versie op de achtergrond en start daarna opnieuw. Je eerder gekozen instellingen, zoals starten met Windows en 'Openen met', blijven staan. + - Na een update meldt kniip in het systeemvak naar welke versie het is bijgewerkt. + - Er wordt alleen gecontroleerd als je de tab opent of op **Opnieuw controleren** klikt; kniip zoekt niet zelf op de achtergrond naar updates. + - Loopt er nog een screencast, of wil je een open knipsel eerst opslaan, dan wacht de update tot dat klaar is. +- **Naamsjabloon voor opslaan.** In de tab **Opslaan** stel je per soort (knipsels en screencasts) zelf in hoe bestanden heten. Je kunt deze codes gebruiken: + - `%J` jaar, `%M` maand en `%D` dag (bijvoorbeeld `2026`, `11`, `09`) + - `%V` de naam van het programma waarvan je een knipsel of opname maakt, bijvoorbeeld `chrome` + - `%R6` zes willekeurige letters en cijfers + - `%N3` een oplopend nummer met drie cijfers (`001`, `002`, ...), per map opnieuw geteld + - `/` maakt een submap; kniip maakt die mappen zelf aan. + + Zo wordt `%J/%M-%D/%V_%N3` bijvoorbeeld `2026/11-09/chrome_001.png`. Onder het veld zie je meteen een voorbeeld. De extensie (`.png`, `.mp4` of `.gif`) zet kniip er zelf achter. Laat je het veld leeg, dan blijft de vertrouwde naam staan: `Knipsel` of `Screencast` met datum en tijd. +- **Autostart-vermelding opruimen.** Heb je kniip ooit als losse exe gestart, dan kan er een vermelding in **Instellingen > Apps > Opstarten** achterblijven. Met `tools\remove-autostart.cmd` haal je die weg. Beheerdersrechten zijn daarvoor niet nodig. + +### Gewijzigd + +- Het versienummer staat nu ook in de naam van de losse exe (`Kniip-1.1.0.exe`), naast de installer (`Kniip-Setup-1.1.0.exe`). +- De handleiding (README) beschrijft nu het downloaden, installeren, verwijderen en publiceren van nieuwe versies, en heeft een schermafbeelding van de editor. + +### Let op + +- Vanaf 1.0.0 moet je deze versie nog één keer zelf installeren met `Kniip-Setup-1.1.0.exe`. Daarna kun je volgende versies bijwerken via **Instellingen > Update**. + +## 1.0.0 - 2026-11-07 + +Eerste stabiele versie. + +- **Annotatie-editor** naast het Windows Knipprogramma: voeg pijlen, lijnen, rechthoeken en andere vormen, tekst, markeringen, genummerde stappen, emoji, muisaanwijzers en vervaging toe aan een knipsel. Met ongedaan maken en opnieuw doen, en een werkbalk die je zelf kunt indelen. +- **Knipsels oppikken**: kniip ziet nieuwe afbeeldingen van het Knipprogramma op het klembord of in de map Schermafbeeldingen, en toont dan een melding of opent meteen de editor. +- **Eigen sneltoetsen** voor een rechthoek of een venster knippen, ook met Print Screen. +- **Screencasts** van een gekozen gebied, met systeemgeluid, microfoon, muisaanwijzer en gemarkeerde muisklikken. Daarna knippen en exporteren als MP4-video of GIF. +- **Opslaan, kopiëren en uploaden**: opslaan in een vaste map of elke keer kiezen, desgewenst ook naar het klembord, en uploaden naar een eigen Zipline-server. Na het uploaden staat de link op het klembord. +- **Windows-integratie**: draait op de achtergrond in het systeemvak, kan starten met Windows en staat in 'Openen met' voor afbeeldingen. +- **Installer** per gebruiker, zonder beheerdersrechten en zonder losse .NET-installatie. diff --git a/README.md b/README.md index 02c5172..cb7fe92 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ kniip is een annotatie-editor die naast het Windows Knipprogramma werkt. Maak ee | Versie | Installer | | ------ | --------- | | Nieuwste versie | [Alle releases](https://git.hibbit.net/rogier/kniip/releases) | +| 1.1.0 | [Kniip-Setup-1.1.0.exe](https://git.hibbit.net/rogier/kniip/releases/download/v1.1.0/Kniip-Setup-1.1.0.exe) | | 1.0.0 | [Kniip-Setup-1.0.0.exe](https://git.hibbit.net/rogier/kniip/releases/download/v1.0.0/Kniip-Setup-1.0.0.exe) | ## Installeren @@ -69,17 +70,29 @@ Vereisten: Bouwen: ```powershell -.\build.ps1 # versie 1.0.0 +.\build.ps1 # versie 1.1.0 .\build.ps1 -Version 1.2.0 # andere versie ``` Het script levert op: - `artifacts\publish\Kniip.exe`: zelfstandige exe, draait zonder installatie +- `artifacts\Kniip-.exe`: dezelfde exe, met het versienummer in de naam - `artifacts\Kniip-Setup-.exe`: de installer (alleen als Inno Setup 6 gevonden is) ## Een nieuwe versie publiceren 1. Bouw met `.\build.ps1 -Version `. 2. Maak op [git.hibbit.net](https://git.hibbit.net/rogier/kniip/releases) een release met tag `v` en voeg `artifacts\Kniip-Setup-.exe` toe als bijlage. -3. Voeg de nieuwe versie toe aan de tabel onder [Downloaden](#downloaden). +3. Schrijf de wijzigingen in markdown in de beschrijving van de release. kniip toont die als changelog onder **Instellingen > Update**. +4. Voeg de nieuwe versie toe aan de tabel onder [Downloaden](#downloaden). + +### Updaten vanuit kniip + +Onder **Instellingen > Update** controleert kniip via de API van git.hibbit.net of er een nieuwere release is en toont de changelog van alle nieuwere versies. **Updaten** downloadt de installer, sluit kniip, installeert stil en start kniip opnieuw. De eerder gekozen integraties blijven behouden. + +Voorwaarden: + +- De repository `rogier/kniip` is publiek; kniip gebruikt geen token. +- De tag is een versienummer (`v1.2.0`); concepten en voorversies worden overgeslagen. +- De bijlage heet `Kniip-Setup-.exe`. Zonder bijlage toont kniip alleen de changelog en een link naar de releasepagina. diff --git a/build.ps1 b/build.ps1 index a614996..7fe9d34 100644 --- a/build.ps1 +++ b/build.ps1 @@ -7,7 +7,7 @@ # .\build.ps1 -Version 1.2.0 param( - [string]$Version = "1.0.0", + [string]$Version = "1.1.0", [string]$Runtime = "win-x64" ) @@ -37,6 +37,11 @@ if ($LASTEXITCODE -ne 0) { throw "dotnet publish is mislukt." } Write-Host "Gepubliceerd: $publishDir\Kniip.exe" -ForegroundColor Green +# Losse exe met versienummer; de installer blijft Kniip.exe uit de publish-map gebruiken. +$portableExe = Join-Path $root "artifacts\Kniip-$Version.exe" +Copy-Item (Join-Path $publishDir "Kniip.exe") $portableExe -Force +Write-Host "Losse exe: $portableExe" -ForegroundColor Green + $iscc = @( "${env:ProgramFiles(x86)}\Inno Setup 6\ISCC.exe", "$env:ProgramFiles\Inno Setup 6\ISCC.exe", diff --git a/installer/Kniip.iss b/installer/Kniip.iss index 59df15f..e166bfa 100644 --- a/installer/Kniip.iss +++ b/installer/Kniip.iss @@ -5,7 +5,7 @@ #define AppName "kniip" #define AppExe "Kniip.exe" #ifndef AppVersion - #define AppVersion "1.0.0" + #define AppVersion "1.1.0" #endif #ifndef PublishDir #define PublishDir "..\artifacts\publish" @@ -75,6 +75,8 @@ Root: HKCU; Subkey: "Software\Classes\.gif\OpenWithProgids"; ValueType: none; Va [Run] Filename: "{app}\{#AppExe}"; Description: "kniip nu starten"; Flags: nowait postinstall skipifsilent +; Stille installatie = update vanuit kniip zelf; daarna opnieuw starten. +Filename: "{app}\{#AppExe}"; Parameters: "--updated"; Flags: nowait; Check: WizardSilent [UninstallRun] Filename: "{cmd}"; Parameters: "/C taskkill /IM {#AppExe} /F"; Flags: runhidden; RunOnceId: "StopKniip" diff --git a/src/Kniip/App.xaml.cs b/src/Kniip/App.xaml.cs index 5d3d381..6acc5c7 100644 --- a/src/Kniip/App.xaml.cs +++ b/src/Kniip/App.xaml.cs @@ -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 _snipWindows = new(); + private List _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) + /// Programmanaam van het geknipte venster, voor %V in het naamsjabloon. + 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? windows = pickWindow ? PhysicalWindow.SnapshotWindows(includeOwn: true) : null; + _snipSnapshot = PhysicalWindow.Snapshot(includeOwn: true); + List? 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 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(); + } + } + + /// Sluit alle vensters; false als een screencast loopt of een editor open blijft. + 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().ToList()) @@ -454,11 +477,28 @@ public partial class App : Application window.Close(); } - if (Windows.OfType().Any()) + return !Windows.OfType().Any(); + } + + /// Start de gedownloade installer en sluit kniip; de installer start kniip daarna opnieuw. + 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(); } diff --git a/src/Kniip/Capture/RegionCaptureWindow.xaml.cs b/src/Kniip/Capture/RegionCaptureWindow.xaml.cs index 0b77d3a..18b9d8b 100644 --- a/src/Kniip/Capture/RegionCaptureWindow.xaml.cs +++ b/src/Kniip/Capture/RegionCaptureWindow.xaml.cs @@ -29,6 +29,9 @@ public partial class RegionCaptureWindow : Window /// Het knipsel, of null bij annuleren. internal event Action? Finished; + /// Geknipt gebied in fysieke schermcoördinaten, gezet vlak voor Finished. + internal Rectangle? SelectedArea { get; private set; } + /// Vensterrechthoeken van boven naar onder; gezet = venster aanklikken in plaats van slepen. internal RegionCaptureWindow(Screen screen, BitmapSource frozen, IReadOnlyList? 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)); } diff --git a/src/Kniip/Editor/EditorWindow.xaml.cs b/src/Kniip/Editor/EditorWindow.xaml.cs index 4ebafc9..428ad77 100644 --- a/src/Kniip/Editor/EditorWindow.xaml.cs +++ b/src/Kniip/Editor/EditorWindow.xaml.cs @@ -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) + /// Programmanaam van het geknipte venster, voor %V in het naamsjabloon. + 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 diff --git a/src/Kniip/Kniip.csproj b/src/Kniip/Kniip.csproj index 1fb5e83..be76dea 100644 --- a/src/Kniip/Kniip.csproj +++ b/src/Kniip/Kniip.csproj @@ -18,7 +18,7 @@ app.manifest kniip Annotatie-editor naast het Windows Knipprogramma - 1.0.0 + 1.1.0 nl;en $(NoWarn);WFAC010 @@ -32,6 +32,7 @@ + diff --git a/src/Kniip/Recording/PhysicalWindow.cs b/src/Kniip/Recording/PhysicalWindow.cs index b13aa1f..83125a1 100644 --- a/src/Kniip/Recording/PhysicalWindow.cs +++ b/src/Kniip/Recording/PhysicalWindow.cs @@ -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); /// Zichtbare topniveauvensters, van boven naar onder; die van kniip zelf alleen met . - public static List SnapshotWindows(bool includeOwn = false) + public static List SnapshotWindows(bool includeOwn = false) => + Snapshot(includeOwn).Select(w => w.Bounds).ToList(); + + /// Programmanaam van het bovenste venster uit onder het midden van . + public static string? ProgramNameAt(IEnumerable windows, Rectangle area) { - var result = new List(); + 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 Snapshot(bool includeOwn = false) + { + var result = new List(); 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); diff --git a/src/Kniip/Recording/RecordingController.cs b/src/Kniip/Recording/RecordingController.cs index a33d479..cf90707 100644 --- a/src/Kniip/Recording/RecordingController.cs +++ b/src/Kniip/Recording/RecordingController.cs @@ -52,8 +52,11 @@ internal sealed class RecordingController public event Action? StageChanged; - /// Pad van een geslaagde opname (tijdelijk MP4-bestand) en het voorgestelde formaat. - public event Action? RecordingFinished; + /// Pad van een geslaagde opname (tijdelijk MP4-bestand), het voorgestelde formaat en de programmanaam van het opgenomen venster. + public event Action? RecordingFinished; + + private List _snapshot = new(); + private string? _windowName; public event Action? Notify; @@ -124,7 +127,8 @@ internal sealed class RecordingController { _format = format; SetStage(RecordingStage.Selecting); - List windows = PhysicalWindow.SnapshotWindows(); + _snapshot = PhysicalWindow.Snapshot(); + List 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 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; diff --git a/src/Kniip/Recording/ScreencastWindow.xaml.cs b/src/Kniip/Recording/ScreencastWindow.xaml.cs index bbba9cc..0eb2f83 100644 --- a/src/Kniip/Recording/ScreencastWindow.xaml.cs +++ b/src/Kniip/Recording/ScreencastWindow.xaml.cs @@ -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) + /// Programmanaam van het opgenomen venster, voor %V in het naamsjabloon. + 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 }; diff --git a/src/Kniip/Settings/AppSettings.cs b/src/Kniip/Settings/AppSettings.cs index 90afe46..6821b63 100644 --- a/src/Kniip/Settings/AppSettings.cs +++ b/src/Kniip/Settings/AppSettings.cs @@ -44,6 +44,9 @@ public sealed class AppSettings /// Map waarin knipsels zonder te vragen worden opgeslagen; leeg betekent elke keer vragen. public string? SnipSaveFolder { get; set; } + + /// Naamsjabloon voor knipsels (zie FileNameTemplate); leeg betekent "Knipsel" met datum en tijd. + public string? SnipFileNameTemplate { get; set; } public Dictionary ToolStyles { get; set; } = new(); /// Recent gekozen emoji (codepunten), nieuwste eerst. @@ -73,6 +76,9 @@ public sealed class AppSettings /// Map waarin screencasts zonder te vragen worden opgeslagen; leeg betekent elke keer vragen. public string? VideoSaveFolder { get; set; } + /// Naamsjabloon voor screencasts (zie FileNameTemplate); leeg betekent "Screencast" met datum en tijd. + public string? VideoFileNameTemplate { get; set; } + /// Gedrag van de knop Opslaan in de editor en de screencast. public SaveButtonChoice SaveButton { get; set; } = SaveButtonChoice.SaveOnly; diff --git a/src/Kniip/Settings/FileNameTemplate.cs b/src/Kniip/Settings/FileNameTemplate.cs new file mode 100644 index 0000000..ab53557 --- /dev/null +++ b/src/Kniip/Settings/FileNameTemplate.cs @@ -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; + +/// +/// 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. +/// +public static class FileNameTemplate +{ + private const int MaxWidth = 32; + private const string RandomChars = "abcdefghijklmnopqrstuvwxyz0123456789"; + + private static readonly Regex Token = new(@"%(?:(?[JMDV])|(?[RN])(?\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."; + + /// + /// Volledig pad in dat nog niet bestaat; de submappen worden aangemaakt. + /// Een leeg sjabloon geeft . + /// + public static string Resolve(string folder, string? template, string windowName, string defaultName, string extension) + { + var randoms = new Dictionary(); + DateTime now = DateTime.Now; + bool counts = HasCounter(template); + for (int number = 1; ; number++) + { + List 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; + } + } + } + + /// Voorbeeld van het pad onder de opslagmap, zonder naar bestaande bestanden te kijken. + 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())) + extension; + + /// Alleen de bestandsnaam zonder extensie, als voorstel in een opslagvenster. + public static string SuggestedName(string? template, string windowName, string defaultName) => + Parts(template, windowName, defaultName, DateTime.Now, 1, new Dictionary())[^1]; + + private static bool HasCounter(string? template) => + !string.IsNullOrWhiteSpace(template) + && Token.Matches(template).Any(m => char.ToUpperInvariant(m.Groups["k"].Value[0]) == 'N'); + + /// Opgeschoonde mapnamen gevolgd door de bestandsnaam zonder extensie. + private static List Parts(string? template, string windowName, string defaultName, DateTime now, int number, Dictionary randoms) + { + var parts = new List(); + 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 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(); + } +} diff --git a/src/Kniip/Settings/SettingsWindow.xaml b/src/Kniip/Settings/SettingsWindow.xaml index 47e6fee..0185715 100644 --- a/src/Kniip/Settings/SettingsWindow.xaml +++ b/src/Kniip/Settings/SettingsWindow.xaml @@ -163,6 +163,12 @@ Margin="6,0,0,0" /> + + + + +