1.1.0
This commit is contained in:
1 parent
e7ed05922e
commit
cc08b00e3b
20 files changed
+1318
-32
No files matched your search
@@ -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.
|
||||||
@@ -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<IReadOnlyList<ReleaseInfo>> GetReleasesAsync(CancellationToken)`: haalt de releases op via één statische `HttpClient` (zoals in [ZiplineUploader.cs](src/Kniip/Upload/ZiplineUploader.cs)) met User-Agent `kniip/<versie>`. 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<string> DownloadAsync(ReleaseInfo, IProgress<double>, CancellationToken)` downloadt naar `%TEMP%\kniip-update\Kniip-Setup-<versie>.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-<versie>.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.
|
||||||
@@ -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.
|
||||||
@@ -25,6 +25,7 @@ kniip is een annotatie-editor die naast het Windows Knipprogramma werkt. Maak ee
|
|||||||
| Versie | Installer |
|
| Versie | Installer |
|
||||||
| ------ | --------- |
|
| ------ | --------- |
|
||||||
| Nieuwste versie | [Alle releases](https://git.hibbit.net/rogier/kniip/releases) |
|
| 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) |
|
| 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
|
## Installeren
|
||||||
@@ -69,17 +70,29 @@ Vereisten:
|
|||||||
Bouwen:
|
Bouwen:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
.\build.ps1 # versie 1.0.0
|
.\build.ps1 # versie 1.1.0
|
||||||
.\build.ps1 -Version 1.2.0 # andere versie
|
.\build.ps1 -Version 1.2.0 # andere versie
|
||||||
```
|
```
|
||||||
|
|
||||||
Het script levert op:
|
Het script levert op:
|
||||||
|
|
||||||
- `artifacts\publish\Kniip.exe`: zelfstandige exe, draait zonder installatie
|
- `artifacts\publish\Kniip.exe`: zelfstandige exe, draait zonder installatie
|
||||||
|
- `artifacts\Kniip-<versie>.exe`: dezelfde exe, met het versienummer in de naam
|
||||||
- `artifacts\Kniip-Setup-<versie>.exe`: de installer (alleen als Inno Setup 6 gevonden is)
|
- `artifacts\Kniip-Setup-<versie>.exe`: de installer (alleen als Inno Setup 6 gevonden is)
|
||||||
|
|
||||||
## Een nieuwe versie publiceren
|
## Een nieuwe versie publiceren
|
||||||
|
|
||||||
1. Bouw met `.\build.ps1 -Version <versie>`.
|
1. Bouw met `.\build.ps1 -Version <versie>`.
|
||||||
2. Maak op [git.hibbit.net](https://git.hibbit.net/rogier/kniip/releases) een release met tag `v<versie>` en voeg `artifacts\Kniip-Setup-<versie>.exe` toe als bijlage.
|
2. Maak op [git.hibbit.net](https://git.hibbit.net/rogier/kniip/releases) een release met tag `v<versie>` en voeg `artifacts\Kniip-Setup-<versie>.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-<versie>.exe`. Zonder bijlage toont kniip alleen de changelog en een link naar de releasepagina.
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
# .\build.ps1 -Version 1.2.0
|
# .\build.ps1 -Version 1.2.0
|
||||||
|
|
||||||
param(
|
param(
|
||||||
[string]$Version = "1.0.0",
|
[string]$Version = "1.1.0",
|
||||||
[string]$Runtime = "win-x64"
|
[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
|
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 = @(
|
$iscc = @(
|
||||||
"${env:ProgramFiles(x86)}\Inno Setup 6\ISCC.exe",
|
"${env:ProgramFiles(x86)}\Inno Setup 6\ISCC.exe",
|
||||||
"$env:ProgramFiles\Inno Setup 6\ISCC.exe",
|
"$env:ProgramFiles\Inno Setup 6\ISCC.exe",
|
||||||
|
|||||||
+3
-1
@@ -5,7 +5,7 @@
|
|||||||
#define AppName "kniip"
|
#define AppName "kniip"
|
||||||
#define AppExe "Kniip.exe"
|
#define AppExe "Kniip.exe"
|
||||||
#ifndef AppVersion
|
#ifndef AppVersion
|
||||||
#define AppVersion "1.0.0"
|
#define AppVersion "1.1.0"
|
||||||
#endif
|
#endif
|
||||||
#ifndef PublishDir
|
#ifndef PublishDir
|
||||||
#define PublishDir "..\artifacts\publish"
|
#define PublishDir "..\artifacts\publish"
|
||||||
@@ -75,6 +75,8 @@ Root: HKCU; Subkey: "Software\Classes\.gif\OpenWithProgids"; ValueType: none; Va
|
|||||||
|
|
||||||
[Run]
|
[Run]
|
||||||
Filename: "{app}\{#AppExe}"; Description: "kniip nu starten"; Flags: nowait postinstall skipifsilent
|
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]
|
[UninstallRun]
|
||||||
Filename: "{cmd}"; Parameters: "/C taskkill /IM {#AppExe} /F"; Flags: runhidden; RunOnceId: "StopKniip"
|
Filename: "{cmd}"; Parameters: "/C taskkill /IM {#AppExe} /F"; Flags: runhidden; RunOnceId: "StopKniip"
|
||||||
|
|||||||
+48
-8
@@ -12,6 +12,7 @@ using Kniip.Interop;
|
|||||||
using Kniip.Notifications;
|
using Kniip.Notifications;
|
||||||
using Kniip.Recording;
|
using Kniip.Recording;
|
||||||
using Kniip.Settings;
|
using Kniip.Settings;
|
||||||
|
using Kniip.Update;
|
||||||
using Microsoft.Win32;
|
using Microsoft.Win32;
|
||||||
using Screen = System.Windows.Forms.Screen;
|
using Screen = System.Windows.Forms.Screen;
|
||||||
|
|
||||||
@@ -23,6 +24,7 @@ public partial class App : Application
|
|||||||
private TrayIcon? _tray;
|
private TrayIcon? _tray;
|
||||||
private SettingsWindow? _settingsWindow;
|
private SettingsWindow? _settingsWindow;
|
||||||
private readonly List<RegionCaptureWindow> _snipWindows = new();
|
private readonly List<RegionCaptureWindow> _snipWindows = new();
|
||||||
|
private List<SnappedWindow> _snipSnapshot = new();
|
||||||
private uint? _ignoredClipboardSequence;
|
private uint? _ignoredClipboardSequence;
|
||||||
|
|
||||||
public static App Instance => (App)Current;
|
public static App Instance => (App)Current;
|
||||||
@@ -134,6 +136,12 @@ public partial class App : Application
|
|||||||
OpenFile(file);
|
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))
|
if (files.Length > 0 || args.Contains("--tray", StringComparer.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
return;
|
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();
|
editor.Show();
|
||||||
BringToFront(editor);
|
BringToFront(editor);
|
||||||
}
|
}
|
||||||
@@ -200,7 +209,8 @@ public partial class App : Application
|
|||||||
var cursorPoint = new System.Drawing.Point(cursor.X, cursor.Y);
|
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.
|
// 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();
|
var frozen = Screen.AllScreens.Select(screen => (screen, ScreenGrabber.Capture(screen.Bounds))).ToList();
|
||||||
foreach ((Screen screen, BitmapSource image) in frozen)
|
foreach ((Screen screen, BitmapSource image) in frozen)
|
||||||
{
|
{
|
||||||
@@ -249,6 +259,7 @@ public partial class App : Application
|
|||||||
HotkeyHook.Capture = null;
|
HotkeyHook.Capture = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
System.Drawing.Rectangle? area = _snipWindows.Select(w => w.SelectedArea).FirstOrDefault(a => a != null);
|
||||||
foreach (RegionCaptureWindow window in _snipWindows)
|
foreach (RegionCaptureWindow window in _snipWindows)
|
||||||
{
|
{
|
||||||
window.Finished -= OnRegionSnipFinished;
|
window.Finished -= OnRegionSnipFinished;
|
||||||
@@ -256,9 +267,12 @@ public partial class App : Application
|
|||||||
}
|
}
|
||||||
|
|
||||||
_snipWindows.Clear();
|
_snipWindows.Clear();
|
||||||
|
List<SnappedWindow> snapshot = _snipSnapshot;
|
||||||
|
_snipSnapshot = new();
|
||||||
if (image != null)
|
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() =>
|
private void UpdateCapturePaused() =>
|
||||||
Capture.Paused = (_tray?.IsPaused ?? false) || Recorder.Stage != RecordingStage.Idle;
|
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();
|
window.Show();
|
||||||
BringToFront(window);
|
BringToFront(window);
|
||||||
}
|
}
|
||||||
@@ -441,12 +455,21 @@ public partial class App : Application
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void ExitApplication()
|
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)
|
if (Recorder.Stage != RecordingStage.Idle)
|
||||||
{
|
{
|
||||||
MessageBox.Show("Er loopt nog een screencast. Stop of annuleer die eerst.", "kniip",
|
MessageBox.Show("Er loopt nog een screencast. Stop of annuleer die eerst.", "kniip",
|
||||||
MessageBoxButton.OK, MessageBoxImage.Information);
|
MessageBoxButton.OK, MessageBoxImage.Information);
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (Window window in Windows.Cast<Window>().ToList())
|
foreach (Window window in Windows.Cast<Window>().ToList())
|
||||||
@@ -454,11 +477,28 @@ public partial class App : Application
|
|||||||
window.Close();
|
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;
|
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();
|
Shutdown();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ public partial class RegionCaptureWindow : Window
|
|||||||
/// <summary>Het knipsel, of null bij annuleren.</summary>
|
/// <summary>Het knipsel, of null bij annuleren.</summary>
|
||||||
internal event Action<BitmapSource?>? Finished;
|
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>
|
/// <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)
|
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);
|
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));
|
Finished?.Invoke(ScreenGrabber.Crop(_frozen, relative));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -68,11 +68,14 @@ public partial class EditorWindow : Window
|
|||||||
private bool _dirty;
|
private bool _dirty;
|
||||||
private bool _discarding;
|
private bool _discarding;
|
||||||
private bool _exported;
|
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();
|
InitializeComponent();
|
||||||
_settings = settings;
|
_settings = settings;
|
||||||
|
_windowName = windowName;
|
||||||
_doc = new EditorDocument(image);
|
_doc = new EditorDocument(image);
|
||||||
|
|
||||||
Surface.Document = _doc;
|
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()
|
private bool Save()
|
||||||
{
|
{
|
||||||
if (_settings.SnipSaveFolder is not { Length: > 0 } target)
|
if (_settings.SnipSaveFolder is not { Length: > 0 } target)
|
||||||
@@ -1441,8 +1446,7 @@ public partial class EditorWindow : Window
|
|||||||
string path;
|
string path;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
System.IO.Directory.CreateDirectory(target);
|
path = FileNameTemplate.Resolve(target, _settings.SnipFileNameTemplate, _windowName ?? "Knipsel", DefaultName, ".png");
|
||||||
path = SaveLocation.UniquePath(target, $"Knipsel {DateTime.Now:yyyy-MM-dd HHmmss}.png");
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -1464,7 +1468,7 @@ public partial class EditorWindow : Window
|
|||||||
{
|
{
|
||||||
Title = "Knipsel opslaan",
|
Title = "Knipsel opslaan",
|
||||||
Filter = "PNG-afbeelding (*.png)|*.png|JPEG-afbeelding (*.jpg)|*.jpg",
|
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",
|
DefaultExt = ".png",
|
||||||
InitialDirectory = folder,
|
InitialDirectory = folder,
|
||||||
AddExtension = true
|
AddExtension = true
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||||
<Product>kniip</Product>
|
<Product>kniip</Product>
|
||||||
<Description>Annotatie-editor naast het Windows Knipprogramma</Description>
|
<Description>Annotatie-editor naast het Windows Knipprogramma</Description>
|
||||||
<Version>1.0.0</Version>
|
<Version>1.1.0</Version>
|
||||||
<SatelliteResourceLanguages>nl;en</SatelliteResourceLanguages>
|
<SatelliteResourceLanguages>nl;en</SatelliteResourceLanguages>
|
||||||
<!-- WPF regelt DPI via app.manifest; de WinForms-analyzer is hier niet van toepassing. -->
|
<!-- WPF regelt DPI via app.manifest; de WinForms-analyzer is hier niet van toepassing. -->
|
||||||
<NoWarn>$(NoWarn);WFAC010</NoWarn>
|
<NoWarn>$(NoWarn);WFAC010</NoWarn>
|
||||||
@@ -32,6 +32,7 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Markdig" Version="1.6.0" />
|
||||||
<PackageReference Include="ScreenRecorderLib" Version="7.0.1" />
|
<PackageReference Include="ScreenRecorderLib" Version="7.0.1" />
|
||||||
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="8.0.0" />
|
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="8.0.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.Diagnostics;
|
||||||
using System.Drawing;
|
using System.Drawing;
|
||||||
|
using System.Linq;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Windows;
|
using System.Windows;
|
||||||
using System.Windows.Interop;
|
using System.Windows.Interop;
|
||||||
@@ -39,9 +41,37 @@ internal static class PhysicalWindow
|
|||||||
NativeMethods.SetWindowDisplayAffinity(Handle(window), NativeMethods.WDA_EXCLUDEFROMCAPTURE);
|
NativeMethods.SetWindowDisplayAffinity(Handle(window), NativeMethods.WDA_EXCLUDEFROMCAPTURE);
|
||||||
|
|
||||||
/// <summary>Zichtbare topniveauvensters, van boven naar onder; die van kniip zelf alleen met <paramref name="includeOwn"/>.</summary>
|
/// <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;
|
int ownPid = Environment.ProcessId;
|
||||||
var className = new StringBuilder(64);
|
var className = new StringBuilder(64);
|
||||||
NativeMethods.EnumWindows((hwnd, _) =>
|
NativeMethods.EnumWindows((hwnd, _) =>
|
||||||
@@ -69,7 +99,7 @@ internal static class PhysicalWindow
|
|||||||
NativeMethods.RECT rect = NativeMethods.GetVisibleWindowRect(hwnd);
|
NativeMethods.RECT rect = NativeMethods.GetVisibleWindowRect(hwnd);
|
||||||
if (rect.Width > 8 && rect.Height > 8)
|
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;
|
return true;
|
||||||
@@ -78,3 +108,5 @@ internal static class PhysicalWindow
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal sealed record SnappedWindow(Rectangle Bounds, uint ProcessId);
|
||||||
@@ -52,8 +52,11 @@ internal sealed class RecordingController
|
|||||||
|
|
||||||
public event Action<RecordingStage>? StageChanged;
|
public event Action<RecordingStage>? StageChanged;
|
||||||
|
|
||||||
/// <summary>Pad van een geslaagde opname (tijdelijk MP4-bestand) en het voorgestelde formaat.</summary>
|
/// <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?>? RecordingFinished;
|
public event Action<string, ScreencastFormat?, string?>? RecordingFinished;
|
||||||
|
|
||||||
|
private List<SnappedWindow> _snapshot = new();
|
||||||
|
private string? _windowName;
|
||||||
|
|
||||||
public event Action<string, string>? Notify;
|
public event Action<string, string>? Notify;
|
||||||
|
|
||||||
@@ -124,7 +127,8 @@ internal sealed class RecordingController
|
|||||||
{
|
{
|
||||||
_format = format;
|
_format = format;
|
||||||
SetStage(RecordingStage.Selecting);
|
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);
|
NativeMethods.GetCursorPos(out NativeMethods.POINT cursor);
|
||||||
var cursorPoint = new System.Drawing.Point(cursor.X, cursor.Y);
|
var cursorPoint = new System.Drawing.Point(cursor.X, cursor.Y);
|
||||||
|
|
||||||
@@ -150,6 +154,8 @@ internal sealed class RecordingController
|
|||||||
}
|
}
|
||||||
|
|
||||||
_selectors.Clear();
|
_selectors.Clear();
|
||||||
|
List<SnappedWindow> snapshot = _snapshot;
|
||||||
|
_snapshot = new();
|
||||||
if (region == null)
|
if (region == null)
|
||||||
{
|
{
|
||||||
SetStage(RecordingStage.Idle);
|
SetStage(RecordingStage.Idle);
|
||||||
@@ -157,6 +163,7 @@ internal sealed class RecordingController
|
|||||||
}
|
}
|
||||||
|
|
||||||
_region = region;
|
_region = region;
|
||||||
|
_windowName = PhysicalWindow.ProgramNameAt(snapshot, region.Bounds);
|
||||||
Screen screen = Screen.AllScreens.FirstOrDefault(s => s.DeviceName == region.DeviceName) ?? Screen.PrimaryScreen!;
|
Screen screen = Screen.AllScreens.FirstOrDefault(s => s.DeviceName == region.DeviceName) ?? Screen.PrimaryScreen!;
|
||||||
|
|
||||||
_frame = new RegionFrameWindow(region.Bounds);
|
_frame = new RegionFrameWindow(region.Bounds);
|
||||||
@@ -299,6 +306,7 @@ internal sealed class RecordingController
|
|||||||
{
|
{
|
||||||
bool discard = _discard;
|
bool discard = _discard;
|
||||||
ScreencastFormat? format = _format;
|
ScreencastFormat? format = _format;
|
||||||
|
string? windowName = _windowName;
|
||||||
EndAll();
|
EndAll();
|
||||||
if (discard)
|
if (discard)
|
||||||
{
|
{
|
||||||
@@ -306,7 +314,7 @@ internal sealed class RecordingController
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
RecordingFinished?.Invoke(path, format);
|
RecordingFinished?.Invoke(path, format, windowName);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnFailed(string error, bool started)
|
private void OnFailed(string error, bool started)
|
||||||
@@ -360,6 +368,7 @@ internal sealed class RecordingController
|
|||||||
_frame?.Close();
|
_frame?.Close();
|
||||||
_frame = null;
|
_frame = null;
|
||||||
_region = null;
|
_region = null;
|
||||||
|
_windowName = null;
|
||||||
_format = null;
|
_format = null;
|
||||||
_discard = false;
|
_discard = false;
|
||||||
_retriedWithoutAudio = false;
|
_retriedWithoutAudio = false;
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ public partial class ScreencastWindow : Window
|
|||||||
|
|
||||||
private readonly string _path;
|
private readonly string _path;
|
||||||
private readonly AppSettings _settings;
|
private readonly AppSettings _settings;
|
||||||
|
private readonly string? _windowName;
|
||||||
private readonly DispatcherTimer _playTimer = new() { Interval = TimeSpan.FromMilliseconds(40) };
|
private readonly DispatcherTimer _playTimer = new() { Interval = TimeSpan.FromMilliseconds(40) };
|
||||||
private TimeSpan _duration;
|
private TimeSpan _duration;
|
||||||
private TimeSpan _start;
|
private TimeSpan _start;
|
||||||
@@ -45,11 +46,13 @@ public partial class ScreencastWindow : Window
|
|||||||
private bool _kept;
|
private bool _kept;
|
||||||
private string? _lastSaved;
|
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();
|
InitializeComponent();
|
||||||
_path = path;
|
_path = path;
|
||||||
_settings = settings;
|
_settings = settings;
|
||||||
|
_windowName = windowName;
|
||||||
|
|
||||||
int[] fpsChoices = FpsChoices.Contains(settings.GifFps) ? FpsChoices : FpsChoices.Append(settings.GifFps).Order().ToArray();
|
int[] fpsChoices = FpsChoices.Contains(settings.GifFps) ? FpsChoices : FpsChoices.Append(settings.GifFps).Order().ToArray();
|
||||||
foreach (int fps in fpsChoices)
|
foreach (int fps in fpsChoices)
|
||||||
@@ -382,8 +385,11 @@ public partial class ScreencastWindow : Window
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private string DefaultFileName =>
|
private string Extension => Format == ScreencastFormat.Gif ? ".gif" : ".mp4";
|
||||||
$"Screencast {DateTime.Now:yyyy-MM-dd HHmmss}{(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 =>
|
private static string DefaultFolder =>
|
||||||
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.MyVideos), "kniip");
|
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.MyVideos), "kniip");
|
||||||
@@ -435,8 +441,7 @@ public partial class ScreencastWindow : Window
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
Directory.CreateDirectory(folder);
|
return FileNameTemplate.Resolve(folder, _settings.VideoFileNameTemplate, _windowName ?? "Screencast", DefaultName, Extension);
|
||||||
return SaveLocation.UniquePath(folder, DefaultFileName);
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -455,7 +460,7 @@ public partial class ScreencastWindow : Window
|
|||||||
{
|
{
|
||||||
Title = "Screencast opslaan",
|
Title = "Screencast opslaan",
|
||||||
Filter = gif ? "GIF-animatie (*.gif)|*.gif" : "MP4-video (*.mp4)|*.mp4",
|
Filter = gif ? "GIF-animatie (*.gif)|*.gif" : "MP4-video (*.mp4)|*.mp4",
|
||||||
FileName = DefaultFileName,
|
FileName = FileNameTemplate.SuggestedName(_settings.VideoFileNameTemplate, _windowName ?? "Screencast", DefaultName) + Extension,
|
||||||
InitialDirectory = folder,
|
InitialDirectory = folder,
|
||||||
AddExtension = true
|
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>
|
/// <summary>Map waarin knipsels zonder te vragen worden opgeslagen; leeg betekent elke keer vragen.</summary>
|
||||||
public string? SnipSaveFolder { get; set; }
|
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();
|
public Dictionary<ToolKind, StyleSet> ToolStyles { get; set; } = new();
|
||||||
|
|
||||||
/// <summary>Recent gekozen emoji (codepunten), nieuwste eerst.</summary>
|
/// <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>
|
/// <summary>Map waarin screencasts zonder te vragen worden opgeslagen; leeg betekent elke keer vragen.</summary>
|
||||||
public string? VideoSaveFolder { get; set; }
|
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>
|
/// <summary>Gedrag van de knop Opslaan in de editor en de screencast.</summary>
|
||||||
public SaveButtonChoice SaveButton { get; set; } = SaveButtonChoice.SaveOnly;
|
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" />
|
Margin="6,0,0,0" />
|
||||||
<TextBlock x:Name="SnipFolderText" VerticalAlignment="Center" TextTrimming="CharacterEllipsis" />
|
<TextBlock x:Name="SnipFolderText" VerticalAlignment="Center" TextTrimming="CharacterEllipsis" />
|
||||||
</DockPanel>
|
</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">
|
<DockPanel Margin="0,0,0,4">
|
||||||
<TextBlock Text="Screencasts" VerticalAlignment="Center" Width="110" />
|
<TextBlock Text="Screencasts" VerticalAlignment="Center" Width="110" />
|
||||||
<Button Tag="Video" DockPanel.Dock="Right" Style="{StaticResource FlatButton}" Content="Vragen" Click="ClearSaveFolder_Click"
|
<Button Tag="Video" DockPanel.Dock="Right" Style="{StaticResource FlatButton}" Content="Vragen" Click="ClearSaveFolder_Click"
|
||||||
@@ -171,8 +177,15 @@
|
|||||||
Margin="6,0,0,0" />
|
Margin="6,0,0,0" />
|
||||||
<TextBlock x:Name="VideoFolderText" VerticalAlignment="Center" TextTrimming="CharacterEllipsis" />
|
<TextBlock x:Name="VideoFolderText" VerticalAlignment="Center" TextTrimming="CharacterEllipsis" />
|
||||||
</DockPanel>
|
</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"
|
<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="SaveOnlyRadio" GroupName="SaveButton" Content="Opslaan" />
|
||||||
<RadioButton x:Name="SaveAndCopyRadio" GroupName="SaveButton" Content="Opslaan en kopiëren" />
|
<RadioButton x:Name="SaveAndCopyRadio" GroupName="SaveButton" Content="Opslaan en kopiëren" />
|
||||||
<TextBlock Style="{StaticResource Hint}"
|
<TextBlock Style="{StaticResource Hint}"
|
||||||
@@ -238,6 +251,29 @@
|
|||||||
<CheckBox x:Name="StraightHighlightBox" Content="Markeerstift trekt standaard een rechte horizontale streep (Shift wisselt)" />
|
<CheckBox x:Name="StraightHighlightBox" Content="Markeerstift trekt standaard een rechte horizontale streep (Shift wisselt)" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</TabItem>
|
</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>
|
</TabControl>
|
||||||
</DockPanel>
|
</DockPanel>
|
||||||
</Window>
|
</Window>
|
||||||
@@ -10,6 +10,7 @@ using System.Windows.Media.Imaging;
|
|||||||
using Kniip.Capture;
|
using Kniip.Capture;
|
||||||
using Kniip.Editor;
|
using Kniip.Editor;
|
||||||
using Kniip.Infrastructure;
|
using Kniip.Infrastructure;
|
||||||
|
using Kniip.Update;
|
||||||
using Kniip.Upload;
|
using Kniip.Upload;
|
||||||
|
|
||||||
namespace Kniip.Settings;
|
namespace Kniip.Settings;
|
||||||
@@ -35,6 +36,9 @@ public partial class SettingsWindow : Window
|
|||||||
private string? _videoFolder;
|
private string? _videoFolder;
|
||||||
private UploaderConfig? _uploader;
|
private UploaderConfig? _uploader;
|
||||||
private bool _resetEditor;
|
private bool _resetEditor;
|
||||||
|
private bool _updateChecked;
|
||||||
|
private ReleaseInfo? _update;
|
||||||
|
private System.Threading.CancellationTokenSource? _updateCts;
|
||||||
|
|
||||||
public SettingsWindow(AppSettings settings)
|
public SettingsWindow(AppSettings settings)
|
||||||
{
|
{
|
||||||
@@ -64,8 +68,13 @@ public partial class SettingsWindow : Window
|
|||||||
? HotkeyHintText
|
? HotkeyHintText
|
||||||
: "De toetsenbordhaak kon niet worden gestart; de sneltoetsen werken nu niet.";
|
: "De toetsenbordhaak kon niet worden gestart; de sneltoetsen werken nu niet.";
|
||||||
Deactivated += (_, _) => StopListening();
|
Deactivated += (_, _) => StopListening();
|
||||||
Closed += (_, _) => StopListening();
|
Closed += (_, _) =>
|
||||||
|
{
|
||||||
|
StopListening();
|
||||||
|
_updateCts?.Cancel();
|
||||||
|
};
|
||||||
|
|
||||||
|
CurrentVersionText.Text = $"Geïnstalleerde versie: {UpdateChecker.CurrentVersion}";
|
||||||
LoadFrom(settings);
|
LoadFrom(settings);
|
||||||
|
|
||||||
MaxHeight = SystemParameters.WorkArea.Height;
|
MaxHeight = SystemParameters.WorkArea.Height;
|
||||||
@@ -86,6 +95,9 @@ public partial class SettingsWindow : Window
|
|||||||
_snipFolder = settings.SnipSaveFolder;
|
_snipFolder = settings.SnipSaveFolder;
|
||||||
_videoFolder = settings.VideoSaveFolder;
|
_videoFolder = settings.VideoSaveFolder;
|
||||||
ShowSaveFolders();
|
ShowSaveFolders();
|
||||||
|
SnipTemplateBox.Text = settings.SnipFileNameTemplate ?? string.Empty;
|
||||||
|
VideoTemplateBox.Text = settings.VideoFileNameTemplate ?? string.Empty;
|
||||||
|
ShowTemplatePreviews();
|
||||||
SaveAndCopyRadio.IsChecked = settings.SaveButton == SaveButtonChoice.SaveAndCopy;
|
SaveAndCopyRadio.IsChecked = settings.SaveButton == SaveButtonChoice.SaveAndCopy;
|
||||||
SaveOnlyRadio.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;
|
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)
|
private void BrowseSaveFolder_Click(object sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
bool snip = sender is FrameworkElement { Tag: "Snip" };
|
bool snip = sender is FrameworkElement { Tag: "Snip" };
|
||||||
@@ -396,6 +421,8 @@ public partial class SettingsWindow : Window
|
|||||||
_settings.Uploader = _uploader;
|
_settings.Uploader = _uploader;
|
||||||
_settings.SnipSaveFolder = _snipFolder;
|
_settings.SnipSaveFolder = _snipFolder;
|
||||||
_settings.VideoSaveFolder = _videoFolder;
|
_settings.VideoSaveFolder = _videoFolder;
|
||||||
|
_settings.SnipFileNameTemplate = NullIfBlank(SnipTemplateBox.Text);
|
||||||
|
_settings.VideoFileNameTemplate = NullIfBlank(VideoTemplateBox.Text);
|
||||||
_settings.SaveButton = SaveAndCopyRadio.IsChecked == true
|
_settings.SaveButton = SaveAndCopyRadio.IsChecked == true
|
||||||
? SaveButtonChoice.SaveAndCopy
|
? SaveButtonChoice.SaveAndCopy
|
||||||
: SaveButtonChoice.SaveOnly;
|
: SaveButtonChoice.SaveOnly;
|
||||||
@@ -464,6 +491,190 @@ public partial class SettingsWindow : Window
|
|||||||
if (e.OriginalSource is TabControl)
|
if (e.OriginalSource is TabControl)
|
||||||
{
|
{
|
||||||
StopListening();
|
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