diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index ef9294a..3a229c3 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -60,6 +60,21 @@ jobs:
path: publish/ProDHCPServer.exe
if-no-files-found: error
+ # A test-signed MSIX (placeholder identity) proves the Store package builds, installs and starts.
+ - name: Build test-signed MSIX
+ shell: pwsh
+ run: tools/build-msix.ps1 -Exe publish/ProDHCPServer.exe -Version "0.1.${{ github.run_number }}" -OutFile msix/ProDHCPServer-test.msix -TestSign
+
+ - name: MSIX smoke test (install, start with settings open, uninstall)
+ shell: pwsh
+ run: tools/msix-smoke-test.ps1 -Package msix/ProDHCPServer-test.msix -OutDir screenshots
+
+ - uses: actions/upload-artifact@v4
+ with:
+ name: ProDHCPServer-msix-test
+ path: msix/
+ if-no-files-found: error
+
# Keep the latest screenshots on a throwaway branch so they can be viewed without downloading artifacts.
- name: Publish screenshots to the ci-screenshots branch
if: always() && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 53bd1f9..9ee6601 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -68,6 +68,21 @@ jobs:
Set-Content -NoNewline -Encoding ascii "dist/$name.sha256" "$hash $name`n"
Get-ChildItem dist
+ # The Store package to upload in Partner Center. It stays unsigned: the Store signs it. Its identity comes from
+ # the repository variables STORE_IDENTITY_NAME, STORE_PUBLISHER and STORE_PUBLISHER_DISPLAY_NAME
+ # (Settings > Secrets and variables > Actions > Variables), copied from Partner Center > Product identity.
+ - name: Build the Microsoft Store package
+ if: vars.STORE_IDENTITY_NAME != ''
+ shell: pwsh
+ env:
+ STORE_IDENTITY_NAME: ${{ vars.STORE_IDENTITY_NAME }}
+ STORE_PUBLISHER: ${{ vars.STORE_PUBLISHER }}
+ STORE_PUBLISHER_DISPLAY_NAME: ${{ vars.STORE_PUBLISHER_DISPLAY_NAME }}
+ run: >
+ tools/build-msix.ps1 -Exe publish/ProDHCPServer.exe -Version ${{ steps.version.outputs.version }}
+ -OutFile "store/ProDHCPServer-$($env:TAG)-x64.msix"
+ -IdentityName $env:STORE_IDENTITY_NAME -Publisher $env:STORE_PUBLISHER -PublisherDisplayName $env:STORE_PUBLISHER_DISPLAY_NAME
+
- name: Create or update the draft release
shell: pwsh
env:
@@ -89,5 +104,6 @@ jobs:
name: release-${{ env.TAG }}
path: |
dist/
+ store/
screenshots/
if-no-files-found: ignore
diff --git a/PRIVACY.md b/PRIVACY.md
new file mode 100644
index 0000000..c637b08
--- /dev/null
+++ b/PRIVACY.md
@@ -0,0 +1,24 @@
+# ProDHCPServer privacy policy
+
+Last updated: 27 October 2026
+
+ProDHCPServer is a DHCP server and device finder that runs on your own Windows laptop. It has no account, no analytics, no advertising and no telemetry. It doesn't send anything about you or your devices to the developer or to anyone else.
+
+## What it keeps on your laptop
+
+- Your settings and saved profiles, in `%LOCALAPPDATA%\ProDHCPServer`.
+- A backup of your network adapter's settings while a session runs (and after a crash, until they are put back), in `%ProgramData%\ProDHCPServer`.
+- Crash reports, in `%ProgramData%\ProDHCPServer`. They stay on the laptop unless you send one yourself.
+- The session log, in memory. It is only saved to a file if you press **Export log**.
+
+These files and an exported log contain network details such as IP addresses, MAC addresses and device names from the network you plug into. None of it leaves the laptop unless you share an exported file yourself.
+
+## What it sends over the network
+
+- On the network you plug the laptop into: DHCP replies, pings, ARP and the discovery requests that find AV devices (for example mDNS, and UPnP requests that read a device's description from the device itself). This traffic stays on that local network.
+- The copy downloaded from GitHub checks GitHub's public API for a newer release when it starts, if you leave that turned on in Settings > Updates. GitHub sees your IP address, as with any website. The Microsoft Store copy doesn't do this; the Store handles its updates.
+- Links in the app (for example to the Npcap download or a release page) open in your web browser.
+
+## Contact
+
+Questions about this policy: open an issue at https://github.com/cookingwithsimon/ProDHCPServer/issues.
diff --git a/README.md b/README.md
index 0d48bae..0316a1b 100644
--- a/README.md
+++ b/README.md
@@ -59,6 +59,12 @@ Most AV kit ships set to DHCP, and on a bench or a fresh site there's usually no
2. Double-click it. Windows asks for administrator rights, because changing adapter settings and serving DHCP require them.
3. The exe isn't code-signed yet, so the first time Windows SmartScreen says "Windows protected your PC". Click **More info**, then **Run anyway**.
+### Updates
+
+When the laptop has internet, ProDHCPServer checks GitHub for a newer release a few seconds after it starts. If there is one, a green banner offers **Install and restart**: it downloads the new exe, checks it against the SHA-256 published with the release, swaps it in place of the old one and restarts. The server has to be stopped first. Nothing is changed if any step fails, and the previous exe is deleted the next time the app starts.
+
+On an isolated AV network the check simply doesn't happen. Turn the start-up check off, or check by hand, under Settings > Updates. On a work laptop that only runs approved programs, Windows may block the new version from starting; the app then puts the old one back and says so.
+
## Using it
1. Connect the device to your laptop's Ethernet port (directly or through a small switch) and power it on.
@@ -104,9 +110,10 @@ Each phase ends with a hardware test pass on real AV equipment before the next o
| What | Where |
|---|---|
-| Your settings (last adapter, profile in use, look and theme) | `%LOCALAPPDATA%\ProDHCPServer\settings.json` |
+| Your settings (last adapter, profile in use, look and theme, update check) | `%LOCALAPPDATA%\ProDHCPServer\settings.json` |
| Your profiles | `%LOCALAPPDATA%\ProDHCPServer\profiles.json` |
| Adapter backup (only while a session runs, or after a crash) | `%ProgramData%\ProDHCPServer\adapter-backup.json` |
+| The previous version, for a moment after an update | `ProDHCPServer.exe.old` next to the exe, deleted on the next start |
While serving, the app also adds two Windows Firewall rules for this exe only: one so it can receive DHCP traffic (UDP 67 and 68), and one on wired adapters so it can hear devices announcing themselves (mDNS, UPnP, vendor discovery). Both are removed on Stop.
@@ -131,9 +138,10 @@ dotnet publish src/ProDHCPServer.App -c Release -r win-x64 -o publish
| `src/ProDHCPServer.App` | The WPF app: UI, elevation prompt, crash handling. |
| `tests/` | Core unit tests (run anywhere) and Windows integration tests (run on the Windows CI runner and never change adapter settings). |
| `docs/` | Hardware test checklists for each phase, the sources for the discovery protocols (`vendor-discovery.md`), and the screenshots used in this README (taken by the CI smoke test). |
-| `tools/` | CI smoke test (launches the app and takes screenshots) and the manufacturer list updater (`python3 tools/update-oui.py`). |
+| `tools/` | CI smoke tests (launch the exe and the MSIX package and take screenshots), the MSIX packager (`build-msix.ps1`) and the manufacturer list updater (`python3 tools/update-oui.py`). |
+| `packaging/msix/` | The Microsoft Store package manifest and tile images. See [docs/microsoft-store.md](docs/microsoft-store.md). |
-**CI.** Every push and pull request runs the **Build and test** workflow on Windows: build, Core and Windows tests, publish the exe, and a smoke test that screenshots the app in light and dark mode. The exe and screenshots are uploaded as run artifacts, so you can try a build from any branch before it's released.
+**CI.** Every push and pull request runs the **Build and test** workflow on Windows: build, Core and Windows tests, publish the exe, and a smoke test that screenshots the app in light and dark mode. It also builds a test-signed MSIX, installs it on the runner and starts it. The exe, the test MSIX and the screenshots are uploaded as run artifacts, so you can try a build from any branch before it's released.
**Contributing.** Work happens on branches with pull requests into `main`. See [CLAUDE.md](CLAUDE.md) for the project rules (they apply to people too), and use the PR template's checklist. Formatting follows `.editorconfig`; check it with `dotnet format whitespace ProDHCPServer.sln --verify-no-changes`.
diff --git a/docs/hardware-test-msix.md b/docs/hardware-test-msix.md
new file mode 100644
index 0000000..7c376de
--- /dev/null
+++ b/docs/hardware-test-msix.md
@@ -0,0 +1,45 @@
+# Microsoft Store package (MSIX) test
+
+About 30 minutes on the **unmanaged** laptop (not the work laptop), with one AV device or any DHCP client and a switch or cable. Run this before the first Store submission.
+
+It tests the Store package with a throwaway test certificate, because only the Store can sign the real one. The point is to check that the packaged app behaves like the exe: the admin prompt, the firewall rule and putting the adapter back.
+
+## Setup
+
+1. Download the `ProDHCPServer-msix-test` artifact from this PR's latest **Build and test** run (Actions tab > run > Artifacts) and unzip it. It has `ProDHCPServer-test.msix` and `ProDHCPServer-test.cer`.
+2. If the GitHub exe is running, close it.
+3. Trust the test certificate: open **PowerShell as administrator** in the unzipped folder and run
+ `Import-Certificate -FilePath .\ProDHCPServer-test.cer -CertStoreLocation Cert:\LocalMachine\TrustedPeople`
+
+Tick each box. If something fails, export the log (**Export…** above the log) and send me the file, or a photo of the message.
+
+## 1. Install and start
+
+- [ ] Double-click `ProDHCPServer-test.msix`. The App Installer window shows ProDHCPServer with **Install**. Install it.
+- [ ] Start **ProDHCPServer** from the Start menu. The app's own "Administrator rights needed" window appears first.
+- [ ] Press **Restart as administrator**. The Windows UAC prompt appears; choose **Yes**. The main window opens.
+- [ ] Open Settings (gear, or Ctrl+,) and scroll to **Updates**. It says "The Microsoft Store installs updates for this copy", with no Check now button and no tick box.
+- [ ] Close the app and start it again from Start. Your look and dark/light choice are remembered.
+
+## 2. Serve DHCP
+
+- [ ] Choose the wired adapter and press **Start**. It starts serving with no firewall or port 67 error.
+- [ ] Plug in the device (or power-cycle it). It gets an address and shows in the list with its manufacturer and ping.
+- [ ] Open **Windows Defender Firewall with Advanced Security** > Inbound Rules. The two "ProDHCPServer" rules are there and their program is under `C:\Program Files\WindowsApps\`.
+- [ ] Press **Stop**. The adapter goes back to how it was (check `ipconfig`), and the two firewall rules are gone.
+
+## 3. Find devices
+
+- [ ] Run the device finder on a device with a static IP. It appears, and **Reach it** works.
+- [ ] If Npcap is installed, the switch port shows. If not, the app says Npcap is missing.
+
+## 4. Crash recovery
+
+- [ ] Press **Start**, then end ProDHCPServer in Task Manager (Details tab > ProDHCPServer.exe > End task).
+- [ ] Start it again from Start. It offers **Restore adapter**; press it, and check `ipconfig`.
+
+## 5. Uninstall
+
+- [ ] Settings > Apps > Installed apps > ProDHCPServer > Uninstall.
+- [ ] In the administrator PowerShell, remove the test certificate:
+ `Get-ChildItem Cert:\LocalMachine\TrustedPeople | Where-Object Subject -eq 'CN=ProDHCPServer Test' | Remove-Item`
diff --git a/docs/hardware-test-updates.md b/docs/hardware-test-updates.md
new file mode 100644
index 0000000..2299de7
--- /dev/null
+++ b/docs/hardware-test-updates.md
@@ -0,0 +1,47 @@
+# Update checker test
+
+About 15 minutes on the unmanaged laptop, with internet. No AV devices needed.
+
+The updater can only update to a release that's already published on GitHub, so this test updates a test build to the newest published release (v1.1.0). v1.1.0 doesn't have the updater itself, so after the update it leaves `ProDHCPServer.exe.old` behind; delete it by hand at the end.
+
+Download `ProDHCPServer.exe` from this PR's latest **Build and test** run (Actions tab → run → Artifacts → `ProDHCPServer-win-x64`) and put it in a folder of its own, for example `C:\Tools\ProDHCPServer\`. Click **More info → Run anyway** on the SmartScreen warning.
+
+Tick each box. If something fails, use **Export log…** and send me the file.
+
+## 1. Test builds don't nag
+
+- [ ] Start the app with internet. After 10 seconds there is **no** green update banner (test builds only check when asked).
+- [ ] Open Settings (gear, or Ctrl+,) and scroll to **Updates**. It says "This is test build 0.1.… It doesn't check for updates by itself; use Check now."
+
+## 2. Offline
+
+- [ ] Unplug or turn off the internet connection (Wi-Fi off, no network cable to the internet).
+- [ ] Press **Check now**. Within about 10 seconds it says "Couldn't reach GitHub to check for updates. That's normal on an isolated AV network…". No error banner, no popup.
+- [ ] The app otherwise works as normal: **Start** and **Stop** a session on a device or an empty port.
+
+## 3. Check and skip
+
+- [ ] Reconnect the internet. Press **Check now**. It says "Version 1.1.0 is available. You have test build 0.1.…", and the green banner appears at the top of the main window with **Install and restart**, **What's new**, **Not now** and **Skip this version**.
+- [ ] **What's new** opens the release page in your normal browser.
+- [ ] **Not now** hides the banner.
+
+## 4. Can't install while serving
+
+- [ ] Press **Check now** again so the banner is back, then **Start** the server.
+- [ ] **Install and restart** is greyed out and the banner says "Stop the server first: installing restarts ProDHCPServer."
+- [ ] Press **Stop**. **Install and restart** becomes clickable.
+
+## 5. Install
+
+- [ ] Press **Install and restart**. The banner shows "Downloading 1.1.0… nn%" with a progress bar, then the window closes and ProDHCPServer opens again by itself within a few seconds, with **no** UAC prompt.
+- [ ] The log's first line says "ProDHCPServer 1.1.0 started (administrator)."
+- [ ] In the folder, `ProDHCPServer.exe` is the new version (right-click → Properties → Details → Product version 1.1.0) and `ProDHCPServer.exe.old` is the test build. Close the app and delete the `.old` file.
+
+## Later, once a release with the updater is out
+
+When the release after this one is published, run the earlier release with the updater and check:
+
+- [ ] A few seconds after start, the green banner appears without pressing anything.
+- [ ] **Skip this version**, then restart the app: no banner. **Check now** in Settings still offers it.
+- [ ] Untick **Check for a new version when ProDHCPServer starts**, restart: no check in the log.
+- [ ] After **Install and restart**, the log says "started (administrator) after updating." and `ProDHCPServer.exe.old` is gone within a few seconds.
diff --git a/docs/microsoft-store.md b/docs/microsoft-store.md
new file mode 100644
index 0000000..ebaf4b4
--- /dev/null
+++ b/docs/microsoft-store.md
@@ -0,0 +1,124 @@
+# Publishing ProDHCPServer in the Microsoft Store
+
+The Store signs MSIX packages itself, so a Store copy runs on laptops without SmartScreen's "Windows protected your PC" warning and without a code-signing certificate of our own. The GitHub exe carries on as before, unsigned.
+
+Facts below were checked against Microsoft's pages on 27 October 2026 (Store policies version 7.20, effective 22 October 2026). Sources are listed at the end.
+
+## The one thing that could stop it: administrator rights
+
+ProDHCPServer needs administrator rights for everything it does (changing adapter settings, firewall rules, listening on UDP port 67). Microsoft's packaging guide says apps that need elevation "won't be accepted into the Store". The capability that allows it, `allowElevation`, is "subject to approval under strict criteria", and Microsoft asks developers to email **reportapp@microsoft.com** with a detailed justification **before** submitting.
+
+So the first step is that email (draft below). Everything else in this guide only matters if Microsoft agrees.
+
+A Store listing of the plain exe doesn't avoid this: for an exe or MSI, policy 10.2.9 requires it to be signed with a certificate from a trusted certificate authority, which is what we're trying to avoid.
+
+## What the repository already does
+
+- **Every push** (Build and test workflow) builds a test MSIX with a placeholder identity, signs it with a throwaway certificate, installs it on the Windows runner, starts it, screenshots it (`msix-settings.png`) and uninstalls it. The package and its `.cer` are in the run's `ProDHCPServer-msix-test` artifact, for the hardware test (`docs/hardware-test-msix.md`).
+- **The Release workflow** also builds the real Store package, `ProDHCPServer-vX.Y.Z-x64.msix`, once the three repository variables below are set. It's unsigned on purpose (the Store signs it) and lands in the run's `release-vX.Y.Z` artifact, not on the GitHub release page.
+- **A Store copy doesn't update itself.** It detects that it's running from an MSIX package, hides the GitHub update check and Install button, and Settings > Updates says the Store installs updates.
+- **Privacy policy:** `PRIVACY.md`, required for desktop apps by policy 10.5.1. Its URL is https://github.com/cookingwithsimon/ProDHCPServer/blob/main/PRIVACY.md once merged.
+
+## Steps only you can do
+
+### 1. Create the developer account (free)
+
+Registration is free for individuals. It needs a personal Microsoft account and an ID check (photo of a government ID plus a selfie). No credit card.
+
+1. Go to [Partner Center](https://partner.microsoft.com/dashboard) and sign up for the Microsoft Store as an **Individual** developer. (A company account is also free now, but needs business verification; an individual account is enough.)
+2. Choose the publisher display name carefully: it shows on the Store page, for example "Simon Henley".
+
+### 2. Ask Microsoft about admin rights
+
+Send the email below from the address on your developer account. Wait for a yes before submitting.
+
+> **To:** reportapp@microsoft.com
+> **Subject:** allowElevation justification for a new desktop app: ProDHCPServer
+>
+> Hello,
+>
+> I'm preparing to submit ProDHCPServer, a free desktop (WPF, full trust) app packaged as MSIX, and I'd like approval to use the allowElevation restricted capability.
+>
+> What the app does: it's a portable DHCP server for audio-visual field technicians. A technician plugs a laptop into a projector, display or control processor that has no network yet, presses Start, and the app gives the device an IP address so it can be configured. It also finds devices that already have a static address.
+>
+> Why it needs administrator rights (there's no unelevated alternative for these):
+> - It changes the Ethernet adapter's IPv4 settings to a static address for the session, and puts the original settings back when the session stops, the app closes or after a crash.
+> - It listens on UDP port 67 (DHCP server) and adds an inbound Windows Firewall rule for its own exe while serving, removed on stop.
+> - It adds static ARP entries so a device can be reached before it has replied.
+>
+> How it elevates: it starts unelevated, explains in plain words why it needs administrator rights, and only then restarts itself with the standard UAC prompt. It installs no driver and no service. Packet capture for switch-port details (LLDP/CDP) is optional and uses Npcap only if the user has installed it separately.
+>
+> Source code: https://github.com/cookingwithsimon/ProDHCPServer
+> Privacy policy: https://github.com/cookingwithsimon/ProDHCPServer/blob/main/PRIVACY.md
+>
+> Could you tell me whether this use would be approved, or what you'd need from me?
+>
+> Thanks,
+> Simon
+
+### 3. Reserve the name
+
+Partner Center > **Apps and games** > **New product** > **MSIX or PWA app**, and reserve **ProDHCPServer**. Reserving is free and holds the name while you wait for Microsoft's answer.
+
+### 4. Copy the package identity into GitHub
+
+In Partner Center, open the app > **Product management** > **Product identity**. Copy three values into the repository's variables (GitHub > the repo > **Settings** > **Secrets and variables** > **Actions** > **Variables** tab > **New repository variable**). They're not secret.
+
+| Variable | Partner Center value |
+|---|---|
+| `STORE_IDENTITY_NAME` | Package/Identity/Name |
+| `STORE_PUBLISHER` | Package/Identity/Publisher (starts with `CN=`) |
+| `STORE_PUBLISHER_DISPLAY_NAME` | Package/Properties/PublisherDisplayName |
+
+### 5. Build the Store package
+
+Run **Actions** > **Release** > **Run workflow** with the version, as for any release (add `.github/release-notes/vX.Y.Z.md` first). When it finishes, download the run's `release-vX.Y.Z` artifact; the `.msix` is in its `store` folder.
+
+Each Store submission needs a higher version than the last one, so make a new release for each.
+
+### 6. Fill in the submission
+
+In Partner Center, start a submission for ProDHCPServer:
+
+- **Pricing and availability:** Free. Markets as you like.
+- **Properties:** category **Developer tools** (or **Utilities & tools**), and the privacy policy URL from above.
+- **Age ratings:** answer the questionnaire. It's a utility with no user content or chat.
+- **Packages:** upload the `.msix` from step 5.
+- **Store listing:** description below, plus screenshots. The CI smoke-test screenshots (`main-dark.png`, `main-light.png`, `settings-dark.png` on the `ci-screenshots` branch) are a good start.
+- **Submission options > Restricted capabilities:** Partner Center asks why the package uses `runFullTrust` and `allowElevation`. Suggested text:
+ - *runFullTrust:* "ProDHCPServer is a full-trust WPF desktop app."
+ - *allowElevation:* "Changing the Ethernet adapter's IP settings, serving DHCP on UDP port 67 and adding a firewall rule need administrator rights. The app asks once at start with the standard UAC prompt. Discussed with reportapp@microsoft.com on [date]."
+- **Notes for certification:** "No account or hardware needed to test. Start the app, choose Yes on the UAC prompt, choose a wired adapter and press Start. With nothing plugged in it shows 'Waiting for a device'. Npcap is optional; without it the app says so and only the switch-port details are missing."
+
+Submit. Certification can take several business days, and Microsoft emails the result.
+
+### Store description (draft)
+
+The first line is required: policy 10.2.4 says a dependency on other software must be disclosed at the start of the description.
+
+> Switch-port details (LLDP/CDP) need Npcap, a free separate download from npcap.com. Everything else works without it.
+>
+> ProDHCPServer is a portable DHCP server for AV field technicians. Plug your laptop into a projector, display, DSP or control processor, press Start, and see straight away whether it got an IP address, and if not, why.
+>
+> - Serves DHCP on the wired adapter you pick, and puts your adapter's settings back when you stop, close the app or after a crash.
+> - Shows each device's manufacturer, address and a live ping graph.
+> - Finds devices that already have a static IP and reaches their subnet in one click.
+> - Saved profiles for subnet, pool, lease time and DHCP options (router, DNS, NTP, option 43/66/150 and custom options).
+> - Plain-English status and troubleshooting, and a session log you can export.
+>
+> Needs administrator rights, because it changes adapter settings and serves DHCP.
+
+## What stays the same, and what to watch
+
+- The GitHub release exe keeps its own update checker. Store copies get updates from the Store.
+- Npcap can't be bundled, because MSIX packages can't install drivers, so the app still offers the npcap.com link.
+- Untested until the MSIX hardware test: whether the elevated restart from an MSIX install behaves exactly like the exe, and whether the firewall rule works with the app's install path under `C:\Program Files\WindowsApps`. Run `docs/hardware-test-msix.md` on the unmanaged laptop before submitting.
+- Microsoft's packaging guide also discourages starting command-line tools, and the app runs `netsh` for adapter and firewall changes. Policy 7.20 doesn't forbid it, but certification might ask about it.
+
+## Sources
+
+- [Free developer registration for individual developers on Microsoft Store](https://blogs.windows.com/windowsdeveloper/2025/09/10/free-developer-registration-for-individual-developers-on-microsoft-store/) (Windows Developer Blog, September 2025)
+- [Publish to Microsoft Store as a company, now with free registration](https://blogs.windows.com/windowsdeveloper/2026/05/07/publish-to-microsoft-store-as-a-company-now-with-free-registration-and-faster-onboarding/) (Windows Developer Blog, May 2026)
+- [Microsoft Store Policies](https://learn.microsoft.com/windows/uwp/publish/store-policies), version 7.20: 10.2.3, 10.2.4, 10.2.9, 10.5.1, 10.6
+- [App capability declarations](https://learn.microsoft.com/windows/apps/package-and-deploy/app-capability-declarations): allowElevation and runFullTrust
+- [Prepare to package a desktop application](https://learn.microsoft.com/windows/msix/desktop/desktop-to-uwp-prepare): elevation and command-line utilities
diff --git a/packaging/msix/AppxManifest.xml b/packaging/msix/AppxManifest.xml
new file mode 100644
index 0000000..efb9847
--- /dev/null
+++ b/packaging/msix/AppxManifest.xml
@@ -0,0 +1,50 @@
+
+
+
+
+
+
+
+ ProDHCPServer
+ $PublisherDisplayName$
+ Images\StoreLogo.png
+ Portable DHCP server for AV field technicians.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packaging/msix/Images/Square150x150Logo.png b/packaging/msix/Images/Square150x150Logo.png
new file mode 100644
index 0000000..101f62f
Binary files /dev/null and b/packaging/msix/Images/Square150x150Logo.png differ
diff --git a/packaging/msix/Images/Square44x44Logo.png b/packaging/msix/Images/Square44x44Logo.png
new file mode 100644
index 0000000..7932c26
Binary files /dev/null and b/packaging/msix/Images/Square44x44Logo.png differ
diff --git a/packaging/msix/Images/StoreLogo.png b/packaging/msix/Images/StoreLogo.png
new file mode 100644
index 0000000..5dc3d20
Binary files /dev/null and b/packaging/msix/Images/StoreLogo.png differ
diff --git a/packaging/msix/Images/Wide310x150Logo.png b/packaging/msix/Images/Wide310x150Logo.png
new file mode 100644
index 0000000..9c811b3
Binary files /dev/null and b/packaging/msix/Images/Wide310x150Logo.png differ
diff --git a/src/ProDHCPServer.App/App.xaml.cs b/src/ProDHCPServer.App/App.xaml.cs
index 7a3dbce..0f2a78b 100644
--- a/src/ProDHCPServer.App/App.xaml.cs
+++ b/src/ProDHCPServer.App/App.xaml.cs
@@ -42,7 +42,10 @@ protected override void OnStartup(StartupEventArgs e)
return;
}
- _singleInstance = new Mutex(true, @"Global\ProDHCPServer.SingleInstance", out bool first);
+ _singleInstance = new Mutex(true, SingleInstanceName, out bool first);
+ // Started by an update: the previous version is still closing, so give it time to let go.
+ bool updated = e.Args.Contains(UpdateViewModel.UpdatedFlag);
+ if (!first && updated) first = WaitForPreviousVersion(_singleInstance);
if (!first)
{
MessageBox.Show("ProDHCPServer is already running. Two copies would fight over the adapter, so this one will close.",
@@ -52,7 +55,9 @@ protected override void OnStartup(StartupEventArgs e)
}
_log = new SessionLog();
- _log.Add(LogSeverity.Info, LogCategory.App, $"ProDHCPServer {Version} started (administrator).");
+ _log.Add(LogSeverity.Info, LogCategory.App, updated
+ ? $"ProDHCPServer {Version} started (administrator) after updating."
+ : $"ProDHCPServer {Version} started (administrator).");
var adapters = new WindowsAdapterControl();
var sessions = new AdapterSessionManager(adapters, new FileSessionStore(Path.Combine(DataFolder, "adapter-backup.json")));
_controller = new ServerController(adapters, sessions, _log, Version);
@@ -78,6 +83,27 @@ protected override void OnStartup(StartupEventArgs e)
window.Show();
}
+ private const string SingleInstanceName = @"Global\ProDHCPServer.SingleInstance";
+
+ ///
+ /// Lets go of the one-copy lock just before an update starts the new version, so it can start even if
+ /// it's an older build without . undoes it.
+ ///
+ public void ReleaseSingleInstance()
+ {
+ _singleInstance?.ReleaseMutex();
+ _singleInstance?.Dispose();
+ _singleInstance = null;
+ }
+
+ public void RetakeSingleInstance() => _singleInstance ??= new Mutex(true, SingleInstanceName, out _);
+
+ private static bool WaitForPreviousVersion(Mutex mutex)
+ {
+ try { return mutex.WaitOne(TimeSpan.FromSeconds(30)); }
+ catch (AbandonedMutexException) { return true; } // it exited without releasing: the mutex is ours now
+ }
+
private void OnUiException(object sender, DispatcherUnhandledExceptionEventArgs e)
{
// Keep running: show the error rather than vanish with the adapter half-configured.
diff --git a/src/ProDHCPServer.App/MainWindow.xaml b/src/ProDHCPServer.App/MainWindow.xaml
index 34fd4f9..1c7e74a 100644
--- a/src/ProDHCPServer.App/MainWindow.xaml
+++ b/src/ProDHCPServer.App/MainWindow.xaml
@@ -144,6 +144,35 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -754,6 +783,31 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/ProDHCPServer.App/ViewModels/MainViewModel.cs b/src/ProDHCPServer.App/ViewModels/MainViewModel.cs
index ac05f6c..13a6077 100644
--- a/src/ProDHCPServer.App/ViewModels/MainViewModel.cs
+++ b/src/ProDHCPServer.App/ViewModels/MainViewModel.cs
@@ -67,6 +67,11 @@ public MainViewModel(IAdapterControl adapters, AdapterSessionManager sessions, S
SaveSettings();
ApplyTheme();
};
+ Update = new UpdateViewModel(App.Version, AppPackage.IsPackaged, log, () => _settings, updated =>
+ {
+ _settings = updated;
+ SaveSettings();
+ }, OpenUrl);
Steps = new ObservableCollection(Enum.GetValues().Select(id => new StepViewModel(id)));
RebuildLog();
@@ -89,10 +94,12 @@ public MainViewModel(IAdapterControl adapters, AdapterSessionManager sessions, S
_timer.Tick += async (_, _) => await RefreshAsync();
_timer.Start();
_ = RefreshAsync();
+ _ = Update.CheckOnStartupAsync();
}
public SessionLog Log { get; }
public SettingsViewModel Settings { get; }
+ public UpdateViewModel Update { get; }
public ObservableCollection Adapters { get; } = [];
public ObservableCollection Steps { get; }
public ObservableCollection Devices { get; } = [];
@@ -124,6 +131,7 @@ public MainViewModel(IAdapterControl adapters, AdapterSessionManager sessions, S
partial void OnIsRunningChanged(bool value)
{
Settings.IsLocked = value;
+ Update.ServerRunning = value;
SweepCommand.NotifyCanExecuteChanged();
}
diff --git a/src/ProDHCPServer.App/ViewModels/UpdateViewModel.cs b/src/ProDHCPServer.App/ViewModels/UpdateViewModel.cs
new file mode 100644
index 0000000..3b354cc
--- /dev/null
+++ b/src/ProDHCPServer.App/ViewModels/UpdateViewModel.cs
@@ -0,0 +1,247 @@
+using System.ComponentModel;
+using System.Diagnostics;
+using System.IO;
+using System.Net.Http;
+using System.Windows;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using ProDHCPServer.Core;
+using ProDHCPServer.Core.Updates;
+
+namespace ProDHCPServer.App.ViewModels;
+
+///
+/// Update checks and in-place updates: a quiet check against GitHub's latest release at start-up (release
+/// builds only), "Check now" in settings, and Install, which downloads the new exe, checks its SHA-256,
+/// swaps it in and restarts. Every failure leaves the current version in place and says what to do next.
+///
+public sealed partial class UpdateViewModel : ObservableObject
+{
+ /// Command-line flag the new exe is started with, so it waits for this one to close.
+ public const string UpdatedFlag = "--updated";
+
+ private static readonly HttpClient Http = CreateClient();
+
+ private readonly UpdateChecker _checker;
+ private readonly SessionLog _log;
+ private readonly Func _getSettings;
+ private readonly Action _saveSettings;
+ private readonly Action _openUrl;
+ private bool _loading;
+
+ public UpdateViewModel(string currentVersion, bool fromStore, SessionLog log, Func getSettings, Action saveSettings, Action openUrl)
+ {
+ _checker = new UpdateChecker(Http, currentVersion);
+ FromStore = fromStore;
+ _log = log;
+ _getSettings = getSettings;
+ _saveSettings = saveSettings;
+ _openUrl = openUrl;
+ _loading = true;
+ CheckOnStart = getSettings().CheckForUpdates;
+ _loading = false;
+ Status = fromStore
+ ? $"You have version {currentVersion}. The Microsoft Store installs updates for this copy."
+ : AppVersion.IsTestBuild(currentVersion)
+ ? $"This is test build {currentVersion}. It doesn't check for updates by itself; use Check now."
+ : $"You have version {currentVersion}.";
+ }
+
+ private static HttpClient CreateClient()
+ {
+ var client = new HttpClient();
+ // GitHub's API refuses requests without a User-Agent.
+ client.DefaultRequestHeaders.UserAgent.ParseAdd($"ProDHCPServer/{App.Version}");
+ return client;
+ }
+
+ /// The single-file exe this process runs from, or null when running from a build folder (updates can't swap that).
+ private static string? ExePath =>
+ // A build folder has ProDHCPServer.dll beside the exe; the published single file doesn't.
+ Environment.ProcessPath is { } p && !File.Exists(Path.ChangeExtension(p, ".dll")) ? p : null;
+
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(Busy))]
+ [NotifyCanExecuteChangedFor(nameof(CheckNowCommand), nameof(InstallCommand))]
+ private bool _isChecking;
+
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(Busy))]
+ [NotifyCanExecuteChangedFor(nameof(CheckNowCommand), nameof(InstallCommand))]
+ private bool _isInstalling;
+
+ /// Download progress, 0 to 100.
+ [ObservableProperty] private double _progress;
+
+ [ObservableProperty] private string _status = "";
+
+ /// Why the last Install didn't work, shown on the banner.
+ [ObservableProperty] private string? _installError;
+
+ /// The newer release, once a check finds one.
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(InstallHint))]
+ [NotifyCanExecuteChangedFor(nameof(InstallCommand), nameof(OpenReleaseCommand))]
+ private ReleaseInfo? _available;
+
+ /// The banner on the main window; cleared by Not now or Skip.
+ [ObservableProperty] private string? _bannerText;
+
+ [ObservableProperty] private bool _checkOnStart;
+
+ /// Set while the DHCP server runs: installing restarts the app, so Stop comes first.
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(InstallHint))]
+ [NotifyCanExecuteChangedFor(nameof(InstallCommand))]
+ private bool _serverRunning;
+
+ public bool Busy => IsChecking || IsInstalling;
+
+ /// Installed from the Microsoft Store: the Store updates it, so the app never checks GitHub or swaps its exe.
+ public bool FromStore { get; }
+
+ public string? InstallHint => Available is null ? null
+ : ServerRunning ? "Stop the server first: installing restarts ProDHCPServer."
+ : !Available.CanInstall || ExePath is null ? "This copy can't update itself. Use Release page to download the new version."
+ : null;
+
+ partial void OnCheckOnStartChanged(bool value)
+ {
+ if (_loading) return;
+ _saveSettings(_getSettings() with { CheckForUpdates = value });
+ }
+
+ /// The start-up check: release builds only, a few seconds after the window opens, and silent unless there is something new.
+ public async Task CheckOnStartupAsync()
+ {
+ if (FromStore) return;
+ CleanUpAfterUpdate();
+ if (!CheckOnStart || AppVersion.IsTestBuild(_checker.CurrentVersion)) return;
+ await Task.Delay(TimeSpan.FromSeconds(5));
+ var result = await RunCheckAsync();
+ if (result.Outcome == UpdateCheckOutcome.Available && result.Latest is { } latest
+ && _getSettings().SkippedUpdate != UpdateChecker.Show(latest.Version))
+ BannerText = $"ProDHCPServer {UpdateChecker.Show(latest.Version)} is available. You have {_checker.CurrentVersion}.";
+ }
+
+ private bool CanCheck() => !Busy && !FromStore;
+
+ [RelayCommand(CanExecute = nameof(CanCheck))]
+ private async Task CheckNow()
+ {
+ var result = await RunCheckAsync();
+ // Asked for by hand, so a skipped version is offered again.
+ if (result.Outcome == UpdateCheckOutcome.Available && result.Latest is { } latest)
+ BannerText = $"ProDHCPServer {UpdateChecker.Show(latest.Version)} is available. You have {_checker.CurrentVersion}.";
+ }
+
+ private async Task RunCheckAsync()
+ {
+ IsChecking = true;
+ Status = "Checking GitHub for a newer version…";
+ try
+ {
+ var result = await _checker.CheckAsync();
+ Status = result.Message;
+ Available = result.Outcome == UpdateCheckOutcome.Available ? result.Latest : null;
+ if (Available is null) BannerText = null;
+ _log.Add(LogSeverity.Info, LogCategory.App, $"Update check: {result.Message}");
+ return result;
+ }
+ finally { IsChecking = false; }
+ }
+
+ private bool CanInstall() => !Busy && !FromStore && !ServerRunning && Available is { CanInstall: true } && ExePath is not null;
+
+ [RelayCommand(CanExecute = nameof(CanInstall))]
+ private async Task Install()
+ {
+ if (Available is not { } release || ExePath is not { } exe) return;
+ var version = UpdateChecker.Show(release.Version);
+ IsInstalling = true;
+ InstallError = null;
+ Progress = 0;
+ Status = $"Downloading {version}…";
+ try
+ {
+ var download = ExeSwap.DownloadPath(exe);
+ await _checker.DownloadAsync(release, download, new Progress(p =>
+ {
+ Progress = p * 100;
+ Status = $"Downloading {version}… {p:P0}";
+ }));
+ Status = $"Installing {version}…";
+ _log.Add(LogSeverity.Info, LogCategory.App, $"Downloaded {release.Exe!.Name} and checked its SHA-256. Installing.");
+ try { ExeSwap.Install(exe, download); }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
+ {
+ UpdateChecker.TryDelete(download);
+ throw new UpdateException($"Couldn't replace ProDHCPServer.exe ({ex.Message}). You still have {_checker.CurrentVersion}.", ex);
+ }
+ if (!TryStartNewVersion(exe, out var why))
+ {
+ ExeSwap.RollBack(exe);
+ throw new UpdateException(why);
+ }
+ _log.Add(LogSeverity.Success, LogCategory.App, $"Restarting into ProDHCPServer {version}.");
+ Application.Current.MainWindow?.Close();
+ }
+ catch (UpdateException ex)
+ {
+ Status = ex.Message;
+ InstallError = ex.Message;
+ _log.Add(LogSeverity.Warning, LogCategory.App, $"Update to {version} didn't install: {ex.Message}", ex.InnerException?.ToString());
+ }
+ finally { IsInstalling = false; }
+ }
+
+ /// Starts the new exe (it inherits this process's administrator rights, so no UAC prompt).
+ private bool TryStartNewVersion(string exe, out string why)
+ {
+ var app = (App)Application.Current;
+ app.ReleaseSingleInstance();
+ try
+ {
+ Process.Start(new ProcessStartInfo(exe, UpdatedFlag) { UseShellExecute = false, WorkingDirectory = Path.GetDirectoryName(exe)! });
+ why = "";
+ return true;
+ }
+ catch (Win32Exception ex)
+ {
+ app.RetakeSingleInstance();
+ // Work laptops with Defender attack surface reduction block new unsigned programs at this point.
+ why = $"Windows wouldn't start the new version ({ex.Message}). Work laptops that only run approved programs often block new versions like this. " +
+ $"You still have {_checker.CurrentVersion}. Ask IT to allow ProDHCPServer, or download it from the release page.";
+ return false;
+ }
+ }
+
+ private bool HasRelease() => Available is not null;
+
+ [RelayCommand(CanExecute = nameof(HasRelease))]
+ private void OpenRelease()
+ {
+ if (Available is { } r) _openUrl(r.PageUrl);
+ }
+
+ [RelayCommand]
+ private void NotNow() => BannerText = null;
+
+ [RelayCommand]
+ private void Skip()
+ {
+ if (Available is { } r) _saveSettings(_getSettings() with { SkippedUpdate = UpdateChecker.Show(r.Version) });
+ BannerText = null;
+ }
+
+ /// Deletes the previous exe a recent update left beside this one. It may still be closing, so this retries for a little while.
+ private void CleanUpAfterUpdate()
+ {
+ if (ExePath is not { } exe) return;
+ if (!File.Exists(ExeSwap.BackupPath(exe)) && !File.Exists(ExeSwap.DownloadPath(exe))) return;
+ _ = Task.Run(async () =>
+ {
+ for (int i = 0; i < 10 && !ExeSwap.CleanUp(exe); i++) await Task.Delay(1000);
+ });
+ }
+}
diff --git a/src/ProDHCPServer.Core/AppSettings.cs b/src/ProDHCPServer.Core/AppSettings.cs
index ca7b932..20b4f52 100644
--- a/src/ProDHCPServer.Core/AppSettings.cs
+++ b/src/ProDHCPServer.Core/AppSettings.cs
@@ -23,6 +23,10 @@ public sealed record AppSettings
public string Theme { get; init; } = "System";
/// Which look the window uses (a name from the App's theme list); unknown values fall back to the default.
public string Look { get; init; } = "Faceplate";
+ /// Look for a newer release on GitHub when the app starts.
+ public bool CheckForUpdates { get; init; } = true;
+ /// A release version the user chose to skip ("1.2.0"); the start-up check won't mention it again.
+ public string? SkippedUpdate { get; init; }
}
public sealed class SettingsStore(string path)
diff --git a/src/ProDHCPServer.Core/Updates/Updater.cs b/src/ProDHCPServer.Core/Updates/Updater.cs
new file mode 100644
index 0000000..735edd3
--- /dev/null
+++ b/src/ProDHCPServer.Core/Updates/Updater.cs
@@ -0,0 +1,269 @@
+using System.Net;
+using System.Net.Http.Headers;
+using System.Security.Cryptography;
+using System.Text.Json;
+
+namespace ProDHCPServer.Core.Updates;
+
+public sealed record ReleaseAsset(string Name, string Url, long Size);
+
+/// A published GitHub release: its version, page, and the exe and checksum the Release workflow uploads.
+public sealed record ReleaseInfo(Version Version, string Tag, string PageUrl, ReleaseAsset? Exe, ReleaseAsset? Checksum)
+{
+ /// True when the release has both files needed to update in place.
+ public bool CanInstall => Exe is not null && Checksum is not null;
+}
+
+public enum UpdateCheckOutcome { UpToDate, Available, Failed }
+
+public sealed record UpdateCheckResult(UpdateCheckOutcome Outcome, ReleaseInfo? Latest, string Message);
+
+/// An update step that failed, with a message that can go straight on screen.
+public sealed class UpdateException(string message, Exception? inner = null) : Exception(message, inner);
+
+public static class AppVersion
+{
+ /// "1.2.3", "v1.2.3" or "1.2.3+sha" as a three-part version, or null.
+ public static Version? Parse(string? text)
+ {
+ if (string.IsNullOrWhiteSpace(text)) return null;
+ var s = text.Trim().TrimStart('v', 'V').Split('+', '-')[0];
+ if (!Version.TryParse(s, out var v)) return null;
+ return new Version(v.Major, v.Minor, Math.Max(v.Build, 0));
+ }
+
+ /// CI builds are numbered 0.1.(run number) and local builds have no version; releases start at 1.0.0.
+ public static bool IsTestBuild(string? version) => Parse(version) is not { Major: > 0 };
+}
+
+///
+/// Checks GitHub for the latest published release and downloads its exe, checking the SHA-256 the Release
+/// workflow publishes beside it. Field laptops are often offline or on an isolated AV network, so a failed
+/// check is an ordinary outcome, not an error.
+///
+public sealed class UpdateChecker(HttpClient http, string currentVersion)
+{
+ public const string LatestReleaseApi = "https://api.github.com/repos/cookingwithsimon/ProDHCPServer/releases/latest";
+ public const string LatestReleasePage = "https://github.com/cookingwithsimon/ProDHCPServer/releases/latest";
+
+ /// The exe is about 70 MB; anything far bigger isn't ours.
+ public const long MaxDownloadBytes = 400L * 1024 * 1024;
+
+ public static readonly TimeSpan CheckTimeout = TimeSpan.FromSeconds(10);
+
+ public string CurrentVersion { get; } = currentVersion;
+
+ public async Task CheckAsync(CancellationToken ct = default)
+ {
+ string json;
+ try
+ {
+ using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
+ timeout.CancelAfter(CheckTimeout);
+ using var request = new HttpRequestMessage(HttpMethod.Get, LatestReleaseApi);
+ request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/vnd.github+json"));
+ using var response = await http.SendAsync(request, timeout.Token);
+ if (response.StatusCode == HttpStatusCode.NotFound)
+ return Failed("GitHub has no published ProDHCPServer release yet.");
+ if (response.StatusCode is HttpStatusCode.Forbidden or HttpStatusCode.TooManyRequests)
+ return Failed("GitHub is limiting update checks from this network for now. Try again in an hour.");
+ if (!response.IsSuccessStatusCode)
+ return Failed($"GitHub answered the update check with an error ({(int)response.StatusCode}). Try again later.");
+ json = await response.Content.ReadAsStringAsync(timeout.Token);
+ }
+ catch (OperationCanceledException) when (!ct.IsCancellationRequested)
+ {
+ return Failed(OfflineMessage);
+ }
+ catch (HttpRequestException)
+ {
+ return Failed(OfflineMessage);
+ }
+
+ if (ParseRelease(json) is not { } latest)
+ return Failed("GitHub's answer didn't look like a ProDHCPServer release. Try again later.");
+ var current = AppVersion.Parse(CurrentVersion);
+ if (current is not null && latest.Version <= current)
+ return new(UpdateCheckOutcome.UpToDate, latest, $"You have the latest version ({Show(current)}).");
+ var have = AppVersion.IsTestBuild(CurrentVersion) ? $"You have test build {CurrentVersion}." : $"You have {Show(current!)}.";
+ return new(UpdateCheckOutcome.Available, latest, $"Version {Show(latest.Version)} is available. {have}");
+ }
+
+ private const string OfflineMessage =
+ "Couldn't reach GitHub to check for updates. That's normal on an isolated AV network; try again when this laptop has internet.";
+
+ private static UpdateCheckResult Failed(string message) => new(UpdateCheckOutcome.Failed, null, message);
+
+ public static string Show(Version v) => $"{v.Major}.{v.Minor}.{v.Build}";
+
+ /// Reads GitHub's release JSON. Drafts and pre-releases are ignored; returns null if it isn't a usable release.
+ public static ReleaseInfo? ParseRelease(string json)
+ {
+ try
+ {
+ using var doc = JsonDocument.Parse(json);
+ var root = doc.RootElement;
+ if (root.ValueKind != JsonValueKind.Object) return null;
+ if (Bool(root, "draft") || Bool(root, "prerelease")) return null;
+ var tag = Str(root, "tag_name");
+ if (AppVersion.Parse(tag) is not { } version) return null;
+ var page = Str(root, "html_url");
+ if (!IsGitHubUrl(page)) page = LatestReleasePage;
+
+ var assets = new List();
+ if (root.TryGetProperty("assets", out var list) && list.ValueKind == JsonValueKind.Array)
+ {
+ foreach (var a in list.EnumerateArray())
+ {
+ var name = Str(a, "name");
+ var url = Str(a, "browser_download_url");
+ if (name is null || !IsGitHubUrl(url)) continue;
+ var size = a.TryGetProperty("size", out var s) && s.TryGetInt64(out var n) ? n : 0;
+ assets.Add(new ReleaseAsset(name, url!, size));
+ }
+ }
+ var exe = assets.FirstOrDefault(a => a.Name.StartsWith("ProDHCPServer", StringComparison.OrdinalIgnoreCase)
+ && a.Name.EndsWith("-win-x64.exe", StringComparison.OrdinalIgnoreCase));
+ var sum = exe is null ? null : assets.FirstOrDefault(a => string.Equals(a.Name, exe.Name + ".sha256", StringComparison.OrdinalIgnoreCase));
+ return new ReleaseInfo(version, tag!, page!, exe, sum);
+ }
+ catch (JsonException)
+ {
+ return null;
+ }
+ }
+
+ private static string? Str(JsonElement e, string name) =>
+ e.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() : null;
+
+ private static bool Bool(JsonElement e, string name) =>
+ e.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.True;
+
+ /// Only download from GitHub over HTTPS, whatever the JSON says.
+ private static bool IsGitHubUrl(string? url) =>
+ Uri.TryCreate(url, UriKind.Absolute, out var uri) && uri.Scheme == Uri.UriSchemeHttps && uri.Host == "github.com";
+
+ /// The hash for from a sha256sum-style file ("hash name"), or a bare hash.
+ public static string? ParseChecksum(string text, string fileName)
+ {
+ foreach (var raw in text.Split('\n'))
+ {
+ var line = raw.Trim();
+ if (line.Length < 64) continue;
+ var hash = line[..64];
+ if (!hash.All(Uri.IsHexDigit)) continue;
+ var rest = line[64..].Trim().TrimStart('*');
+ if (rest.Length == 0 || string.Equals(rest, fileName, StringComparison.OrdinalIgnoreCase)) return hash.ToLowerInvariant();
+ }
+ return null;
+ }
+
+ ///
+ /// Downloads the release's exe to and checks it against the published SHA-256.
+ /// On any failure the partial file is deleted and an explains what happened.
+ ///
+ public async Task DownloadAsync(ReleaseInfo release, string destination, IProgress? progress = null, CancellationToken ct = default)
+ {
+ if (release.Exe is not { } exe || release.Checksum is not { } checksum)
+ throw new UpdateException("This release doesn't have the files needed to update in place. Download it from the release page instead.");
+ try
+ {
+ string expected;
+ try
+ {
+ var text = await http.GetStringAsync(checksum.Url, ct);
+ expected = ParseChecksum(text, exe.Name)
+ ?? throw new UpdateException("The release's checksum file couldn't be read, so the download can't be checked. Nothing was changed.");
+ }
+ catch (HttpRequestException ex)
+ {
+ throw new UpdateException("Couldn't download the update. Check this laptop has internet and try again. Nothing was changed.", ex);
+ }
+
+ using var response = await http.GetAsync(exe.Url, HttpCompletionOption.ResponseHeadersRead, ct);
+ if (!response.IsSuccessStatusCode)
+ throw new UpdateException($"GitHub didn't send the update ({(int)response.StatusCode}). Try again later. Nothing was changed.");
+ var total = response.Content.Headers.ContentLength ?? (exe.Size > 0 ? exe.Size : (long?)null);
+ if (total > MaxDownloadBytes)
+ throw new UpdateException("The update file is far bigger than expected, so it wasn't downloaded. Nothing was changed.");
+
+ using var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256);
+ long received = 0;
+ await using (var source = await response.Content.ReadAsStreamAsync(ct))
+ await using (var file = new FileStream(destination, FileMode.Create, FileAccess.Write, FileShare.None, 81920, useAsync: true))
+ {
+ var buffer = new byte[81920];
+ int read;
+ while ((read = await source.ReadAsync(buffer, ct)) > 0)
+ {
+ received += read;
+ if (received > MaxDownloadBytes)
+ throw new UpdateException("The update file is far bigger than expected, so the download was stopped. Nothing was changed.");
+ hash.AppendData(buffer, 0, read);
+ await file.WriteAsync(buffer.AsMemory(0, read), ct);
+ if (total > 0) progress?.Report(Math.Min(1.0, (double)received / total.Value));
+ }
+ }
+ var actual = Convert.ToHexStringLower(hash.GetHashAndReset());
+ if (actual != expected)
+ throw new UpdateException("The downloaded update didn't match its published checksum, so it was thrown away. Try again; if it keeps happening, download it from the release page. Nothing was changed.");
+ }
+ catch (Exception ex) when (ex is HttpRequestException or IOException or UnauthorizedAccessException or OperationCanceledException or UpdateException)
+ {
+ TryDelete(destination);
+ if (ex is OperationCanceledException && ct.IsCancellationRequested) throw;
+ if (ex is UpdateException) throw;
+ if (ex is IOException or UnauthorizedAccessException)
+ throw new UpdateException($"Couldn't save the update next to ProDHCPServer.exe ({ex.Message}). Nothing was changed.", ex);
+ throw new UpdateException("The download stopped part way. Check this laptop has internet and try again. Nothing was changed.", ex);
+ }
+ }
+
+ public static void TryDelete(string path)
+ {
+ try { File.Delete(path); }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { }
+ }
+}
+
+///
+/// Swaps a downloaded exe in for the running one. Windows won't delete a running exe but will rename it,
+/// so the old one moves aside to "ProDHCPServer.exe.old" and is deleted the next time the app starts.
+///
+public static class ExeSwap
+{
+ public static string DownloadPath(string exePath) => exePath + ".download";
+ public static string BackupPath(string exePath) => exePath + ".old";
+
+ /// Puts in place of , keeping the old one as the backup.
+ public static void Install(string exePath, string newExe)
+ {
+ var backup = BackupPath(exePath);
+ File.Move(exePath, backup, overwrite: true);
+ try
+ {
+ File.Move(newExe, exePath);
+ }
+ catch
+ {
+ File.Move(backup, exePath, overwrite: true);
+ throw;
+ }
+ }
+
+ /// Puts the old exe back after the new one wouldn't start. The new one is deleted.
+ public static void RollBack(string exePath)
+ {
+ var backup = BackupPath(exePath);
+ if (!File.Exists(backup)) return;
+ File.Move(backup, exePath, overwrite: true);
+ }
+
+ /// Deletes the previous version and any half-finished download. Returns true if nothing is left behind.
+ public static bool CleanUp(string exePath)
+ {
+ UpdateChecker.TryDelete(DownloadPath(exePath));
+ UpdateChecker.TryDelete(BackupPath(exePath));
+ return !File.Exists(BackupPath(exePath)) && !File.Exists(DownloadPath(exePath));
+ }
+}
diff --git a/src/ProDHCPServer.Windows/AppPackage.cs b/src/ProDHCPServer.Windows/AppPackage.cs
new file mode 100644
index 0000000..5f1c16c
--- /dev/null
+++ b/src/ProDHCPServer.Windows/AppPackage.cs
@@ -0,0 +1,30 @@
+using System.Runtime.InteropServices;
+using System.Runtime.Versioning;
+
+namespace ProDHCPServer.Windows;
+
+/// Tells a Microsoft Store (MSIX) install apart from the downloaded exe.
+[SupportedOSPlatform("windows")]
+public static partial class AppPackage
+{
+ private const int AppModelErrorNoPackage = 15700;
+
+ [LibraryImport("kernel32.dll")]
+ private static partial int GetCurrentPackageFullName(ref int packageFullNameLength, IntPtr packageFullName);
+
+ ///
+ /// True when this copy came from an MSIX package, so the Store (not the app) installs updates and the exe
+ /// can't be replaced. The path check covers the elevated relaunch, which can start without package identity.
+ ///
+ public static bool IsPackaged { get; } = HasPackageIdentity() || RunsFromWindowsApps(Environment.ProcessPath);
+
+ private static bool HasPackageIdentity()
+ {
+ int length = 0;
+ try { return GetCurrentPackageFullName(ref length, IntPtr.Zero) != AppModelErrorNoPackage; }
+ catch (EntryPointNotFoundException) { return false; }
+ }
+
+ private static bool RunsFromWindowsApps(string? exe) =>
+ exe is not null && exe.Contains(@"\WindowsApps\", StringComparison.OrdinalIgnoreCase);
+}
diff --git a/tests/ProDHCPServer.Core.Tests/UpdaterTests.cs b/tests/ProDHCPServer.Core.Tests/UpdaterTests.cs
new file mode 100644
index 0000000..b2a99f4
--- /dev/null
+++ b/tests/ProDHCPServer.Core.Tests/UpdaterTests.cs
@@ -0,0 +1,189 @@
+using System.Net;
+using System.Security.Cryptography;
+using System.Text;
+using ProDHCPServer.Core.Updates;
+
+namespace ProDHCPServer.Core.Tests;
+
+public class UpdaterTests
+{
+ private const string ExeName = "ProDHCPServer-v1.2.0-win-x64.exe";
+ private const string ExeUrl = "https://github.com/cookingwithsimon/ProDHCPServer/releases/download/v1.2.0/" + ExeName;
+ private const string SumUrl = ExeUrl + ".sha256";
+
+ private static string ReleaseJson(string tag = "v1.2.0", bool draft = false, bool prerelease = false, string exeUrl = ExeUrl) => $$"""
+ {
+ "tag_name": "{{tag}}",
+ "html_url": "https://github.com/cookingwithsimon/ProDHCPServer/releases/tag/{{tag}}",
+ "draft": {{(draft ? "true" : "false")}},
+ "prerelease": {{(prerelease ? "true" : "false")}},
+ "assets": [
+ { "name": "{{ExeName}}", "browser_download_url": "{{exeUrl}}", "size": 11 },
+ { "name": "{{ExeName}}.sha256", "browser_download_url": "{{SumUrl}}", "size": 100 }
+ ]
+ }
+ """;
+
+ /// Answers each URL from a table; anything else is a network failure.
+ private sealed class FakeHandler(Dictionary> routes) : HttpMessageHandler
+ {
+ protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) =>
+ routes.TryGetValue(request.RequestUri!.AbsoluteUri, out var make)
+ ? Task.FromResult(make())
+ : throw new HttpRequestException("No route to host");
+ }
+
+ private static Func Ok(string body) => () => new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(body) };
+ private static Func Ok(byte[] body) => () => new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent(body) };
+
+ private static UpdateChecker Checker(string version, Dictionary> routes) =>
+ new(new HttpClient(new FakeHandler(routes)), version);
+
+ [Theory]
+ [InlineData("1.2.3", "1.2.3")]
+ [InlineData("v1.2.3", "1.2.3")]
+ [InlineData("1.2.3+abc123", "1.2.3")]
+ [InlineData("1.2", "1.2.0")]
+ public void Parses_versions(string text, string expected) => Assert.Equal(Version.Parse(expected), AppVersion.Parse(text));
+
+ [Theory]
+ [InlineData("0.1.57", true)]
+ [InlineData("dev", true)]
+ [InlineData(null, true)]
+ [InlineData("1.0.0", false)]
+ public void Ci_and_local_builds_are_test_builds(string? version, bool test) => Assert.Equal(test, AppVersion.IsTestBuild(version));
+
+ [Fact]
+ public void Reads_release_and_finds_exe_and_checksum()
+ {
+ var r = UpdateChecker.ParseRelease(ReleaseJson())!;
+ Assert.Equal(new Version(1, 2, 0), r.Version);
+ Assert.Equal(ExeName, r.Exe!.Name);
+ Assert.Equal(SumUrl, r.Checksum!.Url);
+ Assert.True(r.CanInstall);
+ }
+
+ [Fact]
+ public void Ignores_drafts_prereleases_odd_tags_and_non_github_downloads()
+ {
+ Assert.Null(UpdateChecker.ParseRelease(ReleaseJson(draft: true)));
+ Assert.Null(UpdateChecker.ParseRelease(ReleaseJson(prerelease: true)));
+ Assert.Null(UpdateChecker.ParseRelease(ReleaseJson(tag: "latest")));
+ Assert.Null(UpdateChecker.ParseRelease("not json"));
+ var elsewhere = UpdateChecker.ParseRelease(ReleaseJson(exeUrl: "https://example.com/" + ExeName))!;
+ Assert.Null(elsewhere.Exe);
+ Assert.False(elsewhere.CanInstall);
+ }
+
+ [Fact]
+ public void Reads_sha256sum_lines()
+ {
+ var hash = new string('a', 64);
+ Assert.Equal(hash, UpdateChecker.ParseChecksum($"{hash.ToUpperInvariant()} {ExeName}\n", ExeName));
+ Assert.Equal(hash, UpdateChecker.ParseChecksum($"{hash} *{ExeName}", ExeName));
+ Assert.Equal(hash, UpdateChecker.ParseChecksum(hash, ExeName));
+ Assert.Null(UpdateChecker.ParseChecksum($"{hash} other.exe", ExeName));
+ Assert.Null(UpdateChecker.ParseChecksum("garbage", ExeName));
+ }
+
+ [Fact]
+ public async Task Newer_release_is_available()
+ {
+ var result = await Checker("1.1.0", new() { [UpdateChecker.LatestReleaseApi] = Ok(ReleaseJson()) }).CheckAsync();
+ Assert.Equal(UpdateCheckOutcome.Available, result.Outcome);
+ Assert.Equal("Version 1.2.0 is available. You have 1.1.0.", result.Message);
+ }
+
+ [Fact]
+ public async Task Same_or_older_release_is_up_to_date()
+ {
+ var same = await Checker("1.2.0", new() { [UpdateChecker.LatestReleaseApi] = Ok(ReleaseJson()) }).CheckAsync();
+ Assert.Equal(UpdateCheckOutcome.UpToDate, same.Outcome);
+ var newer = await Checker("1.3.0", new() { [UpdateChecker.LatestReleaseApi] = Ok(ReleaseJson()) }).CheckAsync();
+ Assert.Equal(UpdateCheckOutcome.UpToDate, newer.Outcome);
+ }
+
+ [Fact]
+ public async Task Test_build_is_offered_the_release()
+ {
+ var result = await Checker("0.1.57", new() { [UpdateChecker.LatestReleaseApi] = Ok(ReleaseJson()) }).CheckAsync();
+ Assert.Equal(UpdateCheckOutcome.Available, result.Outcome);
+ Assert.Contains("test build 0.1.57", result.Message);
+ }
+
+ [Fact]
+ public async Task Offline_fails_quietly_with_a_plain_message()
+ {
+ var result = await Checker("1.1.0", []).CheckAsync();
+ Assert.Equal(UpdateCheckOutcome.Failed, result.Outcome);
+ Assert.Contains("isolated AV network", result.Message);
+ }
+
+ [Fact]
+ public async Task Rate_limit_is_explained()
+ {
+ var result = await Checker("1.1.0", new() { [UpdateChecker.LatestReleaseApi] = () => new HttpResponseMessage(HttpStatusCode.Forbidden) }).CheckAsync();
+ Assert.Equal(UpdateCheckOutcome.Failed, result.Outcome);
+ Assert.Contains("limiting", result.Message);
+ }
+
+ [Fact]
+ public async Task Download_checks_the_sha256()
+ {
+ var body = Encoding.ASCII.GetBytes("new version");
+ var hash = Convert.ToHexStringLower(SHA256.HashData(body));
+ var release = UpdateChecker.ParseRelease(ReleaseJson())!;
+ using var dir = new TempDir();
+ var dest = Path.Combine(dir.Path, "ProDHCPServer.exe.download");
+
+ var good = Checker("1.1.0", new() { [SumUrl] = Ok($"{hash} {ExeName}\n"), [ExeUrl] = Ok(body) });
+ var progress = new List();
+ await good.DownloadAsync(release, dest, new SyncProgress(progress.Add));
+ Assert.Equal(body, File.ReadAllBytes(dest));
+ Assert.Equal(1.0, progress[^1]);
+
+ File.Delete(dest);
+ var tampered = Checker("1.1.0", new() { [SumUrl] = Ok($"{hash} {ExeName}\n"), [ExeUrl] = Ok(Encoding.ASCII.GetBytes("bad version")) });
+ var ex = await Assert.ThrowsAsync(() => tampered.DownloadAsync(release, dest));
+ Assert.Contains("didn't match", ex.Message);
+ Assert.False(File.Exists(dest));
+
+ var offline = Checker("1.1.0", []);
+ await Assert.ThrowsAsync(() => offline.DownloadAsync(release, dest));
+ Assert.False(File.Exists(dest));
+ }
+
+ [Fact]
+ public void Swap_keeps_the_old_exe_until_clean_up_and_can_roll_back()
+ {
+ using var dir = new TempDir();
+ var exe = Path.Combine(dir.Path, "ProDHCPServer.exe");
+ File.WriteAllText(exe, "old");
+ File.WriteAllText(ExeSwap.DownloadPath(exe), "new");
+
+ ExeSwap.Install(exe, ExeSwap.DownloadPath(exe));
+ Assert.Equal("new", File.ReadAllText(exe));
+ Assert.Equal("old", File.ReadAllText(ExeSwap.BackupPath(exe)));
+
+ ExeSwap.RollBack(exe);
+ Assert.Equal("old", File.ReadAllText(exe));
+ Assert.False(File.Exists(ExeSwap.BackupPath(exe)));
+
+ File.WriteAllText(ExeSwap.BackupPath(exe), "older");
+ Assert.True(ExeSwap.CleanUp(exe));
+ Assert.False(File.Exists(ExeSwap.BackupPath(exe)));
+ Assert.Equal("old", File.ReadAllText(exe));
+ }
+
+ /// Progress<T> reports on the thread pool; this one reports straight away so the test can read it.
+ private sealed class SyncProgress(Action report) : IProgress
+ {
+ public void Report(double value) => report(value);
+ }
+
+ private sealed class TempDir : IDisposable
+ {
+ public string Path { get; } = Directory.CreateTempSubdirectory("prodhcp-update-").FullName;
+ public void Dispose() => Directory.Delete(Path, recursive: true);
+ }
+}
diff --git a/tools/build-msix.ps1 b/tools/build-msix.ps1
new file mode 100644
index 0000000..7068bf7
--- /dev/null
+++ b/tools/build-msix.ps1
@@ -0,0 +1,59 @@
+# Wraps the published single-file exe in an MSIX package for the Microsoft Store.
+# The Store signs what it publishes, so the Store package is left unsigned. -TestSign signs a copy with a
+# throwaway self-signed certificate instead, so a test laptop can install it after trusting the .cer.
+# Windows only: needs MakeAppx.exe and SignTool.exe from the Windows SDK (installed on GitHub's Windows runners).
+param(
+ [Parameter(Mandatory)] [string] $Exe,
+ # x.y.z; the Store wants the fourth part to be 0.
+ [Parameter(Mandatory)] [string] $Version,
+ [Parameter(Mandatory)] [string] $OutFile,
+ # Partner Center > the app > Product identity: Package/Identity/Name, Package/Identity/Publisher and Package/Properties/PublisherDisplayName.
+ [string] $IdentityName = "ProDHCPServer.Test",
+ [string] $Publisher = "CN=ProDHCPServer Test",
+ [string] $PublisherDisplayName = "ProDHCPServer test build",
+ [switch] $TestSign
+)
+$ErrorActionPreference = "Stop"
+if ($Version -notmatch '^\d+\.\d+\.\d+$') { throw "Version '$Version' must look like 1.2.3" }
+
+$sdkBin = Get-ChildItem "${env:ProgramFiles(x86)}\Windows Kits\10\bin\10.*\x64" -Directory |
+ Where-Object { Test-Path (Join-Path $_.FullName "makeappx.exe") } |
+ Sort-Object { [version]$_.Parent.Name } | Select-Object -Last 1
+if (-not $sdkBin) { throw "Can't find MakeAppx.exe. Install the Windows 10/11 SDK." }
+$makeAppx = Join-Path $sdkBin.FullName "makeappx.exe"
+$signTool = Join-Path $sdkBin.FullName "signtool.exe"
+
+$root = Split-Path $PSScriptRoot -Parent
+$layout = Join-Path ([System.IO.Path]::GetTempPath()) "ProDHCPServer-msix-$([guid]::NewGuid().ToString('N'))"
+New-Item -ItemType Directory -Path $layout | Out-Null
+try {
+ Copy-Item $Exe (Join-Path $layout "ProDHCPServer.exe")
+ Copy-Item (Join-Path $root "packaging/msix/Images") (Join-Path $layout "Images") -Recurse
+ $manifest = Get-Content (Join-Path $root "packaging/msix/AppxManifest.xml") -Raw
+ $values = @{ IdentityName = $IdentityName; Publisher = $Publisher; PublisherDisplayName = $PublisherDisplayName; Version = "$Version.0" }
+ foreach ($key in $values.Keys) { $manifest = $manifest.Replace("`$$key`$", [System.Security.SecurityElement]::Escape($values[$key])) }
+ if ($manifest -match '\$\w+\$') { throw "AppxManifest.xml still has a placeholder: $($Matches[0])" }
+ Set-Content (Join-Path $layout "AppxManifest.xml") $manifest -Encoding utf8
+
+ New-Item -ItemType Directory -Force -Path (Split-Path $OutFile -Parent) | Out-Null
+ & $makeAppx pack /d $layout /p $OutFile /o
+ if ($LASTEXITCODE -ne 0) { throw "MakeAppx failed with exit code $LASTEXITCODE." }
+
+ if ($TestSign) {
+ # The certificate subject has to match the manifest's Publisher exactly.
+ $cert = New-SelfSignedCertificate -Type Custom -Subject $Publisher -KeyUsage DigitalSignature -FriendlyName "ProDHCPServer test signing" `
+ -CertStoreLocation Cert:\CurrentUser\My -NotAfter (Get-Date).AddYears(1) `
+ -TextExtension @("2.5.29.37={text}1.3.6.1.5.5.7.3.3", "2.5.29.19={text}")
+ try {
+ $password = ConvertTo-SecureString ([guid]::NewGuid().ToString()) -AsPlainText -Force
+ $pfx = Join-Path $layout "test.pfx"
+ Export-PfxCertificate -Cert $cert -FilePath $pfx -Password $password | Out-Null
+ & $signTool sign /fd SHA256 /f $pfx /p ([System.Net.NetworkCredential]::new("", $password).Password) $OutFile
+ if ($LASTEXITCODE -ne 0) { throw "SignTool failed with exit code $LASTEXITCODE." }
+ Export-Certificate -Cert $cert -FilePath ([System.IO.Path]::ChangeExtension($OutFile, ".cer")) | Out-Null
+ }
+ finally { Remove-Item $cert.PSPath -Force }
+ }
+ Get-Item $OutFile
+}
+finally { Remove-Item $layout -Recurse -Force -ErrorAction SilentlyContinue }
diff --git a/tools/msix-smoke-test.ps1 b/tools/msix-smoke-test.ps1
new file mode 100644
index 0000000..f7dc90f
--- /dev/null
+++ b/tools/msix-smoke-test.ps1
@@ -0,0 +1,37 @@
+# Installs a test-signed MSIX on the CI runner, starts the packaged app with settings open, checks it stays up,
+# saves a screenshot, then uninstalls it and removes the test certificate. Needs an elevated runner.
+param(
+ [Parameter(Mandatory)] [string] $Package,
+ [string] $OutDir = "screenshots"
+)
+$ErrorActionPreference = "Stop"
+Add-Type -AssemblyName System.Windows.Forms, System.Drawing
+New-Item -ItemType Directory -Force -Path $OutDir | Out-Null
+
+$cer = [System.IO.Path]::ChangeExtension($Package, ".cer")
+$cert = Import-Certificate -FilePath $cer -CertStoreLocation Cert:\LocalMachine\TrustedPeople
+try {
+ Add-AppxPackage -Path $Package
+ $installed = Get-AppxPackage -Name "ProDHCPServer.Test"
+ if (-not $installed) { throw "The MSIX installed but Windows doesn't list it." }
+ Write-Host "Installed $($installed.PackageFullName) to $($installed.InstallLocation)"
+ try {
+ $p = Start-Process -FilePath (Join-Path $installed.InstallLocation "ProDHCPServer.exe") -ArgumentList "--open-settings" -PassThru
+ Start-Sleep -Seconds 15
+ if ($p.HasExited) {
+ Get-ChildItem (Join-Path $env:ProgramData "ProDHCPServer") -Filter "crash-*.txt" -ErrorAction SilentlyContinue | ForEach-Object { Get-Content $_.FullName }
+ throw "The packaged ProDHCPServer exited during startup with code $($p.ExitCode)."
+ }
+ $b = [System.Windows.Forms.Screen]::PrimaryScreen.Bounds
+ $bmp = New-Object System.Drawing.Bitmap $b.Width, $b.Height
+ $g = [System.Drawing.Graphics]::FromImage($bmp)
+ $g.CopyFromScreen($b.Location, [System.Drawing.Point]::Empty, $b.Size)
+ $bmp.Save((Join-Path $OutDir "msix-settings.png"), [System.Drawing.Imaging.ImageFormat]::Png)
+ $g.Dispose(); $bmp.Dispose()
+ Write-Host "Packaged app running, screenshot saved. Main window title: '$($p.MainWindowTitle)'"
+ Stop-Process -Id $p.Id -Force
+ Start-Sleep -Seconds 2
+ }
+ finally { Remove-AppxPackage -Package $installed.PackageFullName }
+}
+finally { Remove-Item $cert.PSPath -Force }