From 9b8785dbf13bbe6499fdfd4074d1ed0ec85214e5 Mon Sep 17 00:00:00 2001 From: Arne Date: Fri, 13 Nov 2026 15:59:25 +0100 Subject: [PATCH 1/3] feat(desktop): the Store version leaves updating to the Microsoft Store MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Huismus.exe notices it runs from an MSIX package and sets HUISMUS_STORE. Then it never looks at GitHub, downloads or installs a version, clears what the installer left in the update status, and makes a backup before the migrations a new Store version brings. Settings → Updates only says the Store updates Huismus. Playbook: n.v.t. — alleen voor de Windows-app van Huismus Co-Authored-By: Claude Opus 5.5 --- desktop/app.py | 7 + desktop/server.py | 6 + desktop/windows.py | 19 ++ huismus/business/topics.py | 10 + huismus/config/settings.py | 4 + huismus/core/update_views.py | 10 +- huismus/core/updates.py | 53 +++++- huismus/locale/en/LC_MESSAGES/django.po | 9 +- huismus/locale/nl/LC_MESSAGES/django.po | 5 +- huismus/templates/business/updates.html | 6 + tests/desktop/test_store.py | 235 ++++++++++++++++++++++++ 11 files changed, 355 insertions(+), 9 deletions(-) create mode 100644 tests/desktop/test_store.py diff --git a/desktop/app.py b/desktop/app.py index 44b3da1..bc53574 100644 --- a/desktop/app.py +++ b/desktop/app.py @@ -428,6 +428,13 @@ def _configure_django(data): os.environ["HUISMUS_DESKTOP"] = "true" os.environ["HUISMUS_DATA_DIR"] = str(data) os.environ["HUISMUS_WINDOW_TOKEN"] = _window_token() + if sys.platform == "win32": + from desktop import windows + + if windows.packaged(): + # The Microsoft Store version: the Store updates it (SPEC.md M11). + os.environ["HUISMUS_STORE"] = "true" + logger.info("Huismus uit de Microsoft Store: bijwerken gaat via de Store") if getattr(sys, "frozen", False): # Fontconfig (for WeasyPrint's PDFs) looks for its configuration # where Homebrew or MSYS2 put it on the build machine; the app diff --git a/desktop/server.py b/desktop/server.py index 6d43c5a..62501ce 100644 --- a/desktop/server.py +++ b/desktop/server.py @@ -61,7 +61,13 @@ def start(self) -> str: from django.core.wsgi import get_wsgi_application django.setup() + from django.conf import settings + try: + if settings.STORE: + from huismus.core import updates + + updates.start_from_store() call_command("migrate", interactive=False, verbosity=0) except Exception as error: raise MigrationFailed() from error diff --git a/desktop/windows.py b/desktop/windows.py index 8418c11..2854dd3 100644 --- a/desktop/windows.py +++ b/desktop/windows.py @@ -94,3 +94,22 @@ def each(hwnd, _): if user32.IsIconic(found[0]): user32.ShowWindow(found[0], SW_RESTORE) return bool(user32.SetForegroundWindow(found[0])) + + +# What GetCurrentPackageFullName says outside a package. +APPMODEL_ERROR_NO_PACKAGE = 15700 + + +def packaged(kernel32=None) -> bool: + """Whether this Huismus.exe runs from the Microsoft Store's package + (desktop/msix.py) rather than from the installer's folder. Inside a + package Windows knows its full name: asked with an empty buffer, it + says the buffer is too small instead of "no package".""" + import ctypes + + if kernel32 is None: + kernel32 = ctypes.windll.kernel32 + length = ctypes.c_uint32(0) + return kernel32.GetCurrentPackageFullName(ctypes.byref(length), None) != ( + APPMODEL_ERROR_NO_PACKAGE + ) diff --git a/huismus/business/topics.py b/huismus/business/topics.py index fabc30a..2ea1825 100644 --- a/huismus/business/topics.py +++ b/huismus/business/topics.py @@ -286,6 +286,16 @@ def _updates_card(ok, off) -> dict: line = _("Huismus %(version)s is er") % {"version": release.version} else: line = _("Versie %(version)s") % {"version": updates.current_version()} + if updates.by_store(): + # No switch, so no Aan/Uit (huismus-intern/besluiten.md, 2026-11-13). + return { + "title": _("Updates"), + "intro": _("De Microsoft Store werkt Huismus bij."), + "slug": "updates", + "url": reverse("business:updates"), + "line": line, + "pill": None, + } on, off_text = pgettext_lazy("setting", "Aan"), pgettext_lazy("setting", "Uit") return { "title": _("Updates"), diff --git a/huismus/config/settings.py b/huismus/config/settings.py index 7ce96ec..0c102dc 100644 --- a/huismus/config/settings.py +++ b/huismus/config/settings.py @@ -39,6 +39,10 @@ # carries WINDOW_TOKEN (huismus/core/desktop.py), and only on this computer. DESKTOP = os.environ.get("HUISMUS_DESKTOP", "false").lower() == "true" WINDOW_TOKEN = os.environ.get("HUISMUS_WINDOW_TOKEN", "") +# The Windows app from the Microsoft Store (desktop/msix.py): the Store +# updates it, so Huismus never looks for, downloads or installs a version +# itself (huismus/core/updates.py). desktop/app.py sets it. +STORE = DESKTOP and os.environ.get("HUISMUS_STORE", "false").lower() == "true" # Huismus's app at Dropbox (huismus-intern/cloud-registratie.md), for backups straight # to Dropbox (huismus/core/cloud_backups.py). An app key is no secret: an app that diff --git a/huismus/core/update_views.py b/huismus/core/update_views.py index c3032d8..2245b73 100644 --- a/huismus/core/update_views.py +++ b/huismus/core/update_views.py @@ -28,7 +28,7 @@ def _desktop_only(): - if not settings.DESKTOP: + if not settings.DESKTOP or updates.by_store(): raise Http404 @@ -65,6 +65,12 @@ def _check_and_prepare(): @login_required def page(request): + if updates.by_store(): + # Only the version and who updates it: nothing to switch or press + # (huismus-intern/besluiten.md, 2026-11-13). + return render( + request, "business/updates.html", {"store": True, "version": updates.current_version()} + ) if request.method == "POST": enabled = request.POST.get("enabled") == "on" if enabled: @@ -105,7 +111,7 @@ def check(request): look. The one place Huismus looks during a request, because the user asked and waits for the answer (at most TIMEOUT_SECONDS). The desktop app then downloads in the background, as after any look.""" - if not updates.load_status().enabled: + if not updates.load_status().enabled or updates.by_store(): return redirect("business:updates") try: release = updates.look_now() diff --git a/huismus/core/updates.py b/huismus/core/updates.py index a6d1b19..19864b7 100644 --- a/huismus/core/updates.py +++ b/huismus/core/updates.py @@ -226,6 +226,8 @@ def check(now: datetime | None = None) -> Release | None: def look_now(now: datetime | None = None) -> Release | None: """check(), but UpdateError("look") when GitHub can't be reached, so "Nu zoeken" on Instellingen → Updates can say so.""" + if by_store(): + return None now = now or timezone.localtime() try: releases = json.loads(_fetch(releases_url())) @@ -237,11 +239,18 @@ def look_now(now: datetime | None = None) -> Release | None: return release +def by_store() -> bool: + """The Windows app from the Microsoft Store (settings.STORE): the Store + updates it, so Huismus never looks, downloads or installs itself + (SPEC.md M11, "Microsoft Store"; huismus-intern/besluiten.md, 2026-11-13).""" + return settings.STORE + + def check_if_due(now: datetime | None = None) -> None: """At start and once a day after (desktop/app.py, start_hourly_thread), if it's on.""" status = load_status() - if not status.enabled: + if not status.enabled or by_store(): return now = now or timezone.localtime() last = datetime.fromisoformat(status.last_check) if status.last_check else None @@ -254,7 +263,7 @@ def available() -> Release | None: found earlier, and only while looking is on.""" status = load_status() release = status.release - if not status.enabled or release is None: + if not status.enabled or release is None or by_store(): return None # Found by an older version than this one (after updating by hand). return release if is_newer(release.version, current_version()) else None @@ -505,7 +514,7 @@ def install_at_start() -> bool: tries again, and the overview's backup warning says what's wrong with the folder (huismus-intern/besluiten.md, 2026-11-04).""" release = waiting() - if release is None or installer is None: + if release is None or installer is None or by_store(): return False try: install(release) @@ -516,3 +525,41 @@ def install_at_start() -> bool: update_status(auto_failed=release.version, not_installed=release.version) return False return True + + +# -- The Microsoft Store version ------------------------------------------------ + + +def start_from_store() -> None: + """Before the migrations at every start of the Store version + (desktop/server.py). + + Someone who moved from the installer may still have its update status: + a downloaded installer, a version found or one that failed. None of it + applies here, so it goes. + + The Store replaced the app while Huismus was closed, so the backup that + install() makes before an update is made here: when this version brings + migrations an existing administration lacks, as on Docker + (manage.py migrate_with_backup). Recorded as pending, so failing + migrations get the screen that offers this backup back (desktop/app.py), + and the first start that works clears it. SafetyBackupError when the + backup can't be made: then nothing is migrated.""" + from . import auto_backups + from .backup import sample_data_in_use + from .management.commands.migrate_with_backup import has_administration, pending_migrations + + status = load_status() + _forget_download(status) + update_status(available={}, pending={}, not_installed="", auto_failed="", download_failed="") + if not (pending_migrations() and has_administration() and not sample_data_in_use()): + return + logger.info("Nieuwe versie uit de Store (%s): eerst een back-up", current_version()) + backup = auto_backups.safety_backup() + update_status( + pending={ + "from_version": status.seen_version, + "to_version": current_version(), + "backup": str(backup), + } + ) diff --git a/huismus/locale/en/LC_MESSAGES/django.po b/huismus/locale/en/LC_MESSAGES/django.po index fce3d28..858f573 100644 --- a/huismus/locale/en/LC_MESSAGES/django.po +++ b/huismus/locale/en/LC_MESSAGES/django.po @@ -4,7 +4,7 @@ msgid "" msgstr "" "Project-Id-Version: huismus\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-11-13 08:06+0100\n" +"POT-Creation-Date: 2026-11-13 15:46+0100\n" "PO-Revision-Date: 2026-09-25 08:36+0200\n" "Last-Translator: \n" "Language-Team: \n" @@ -631,6 +631,9 @@ msgstr "Version %(version)s" msgid "Updates" msgstr "Updates" +msgid "De Microsoft Store werkt Huismus bij." +msgstr "The Microsoft Store keeps Huismus up to date." + msgid "Huismus werkt zichzelf bij." msgstr "Huismus updates itself." @@ -5848,8 +5851,8 @@ msgid "" "Laat het uit zolang je Huismus gebruikt, anders blokkeert Windows de app " "weer. Je levert er wat bescherming mee in." msgstr "" -"Keep it off for as long as you use Huismus, or Windows blocks the app " -"again. You give up some protection with it." +"Keep it off for as long as you use Huismus, or Windows blocks the app again. " +"You give up some protection with it." msgid "Meer over de desktop-app" msgstr "More about the desktop app" diff --git a/huismus/locale/nl/LC_MESSAGES/django.po b/huismus/locale/nl/LC_MESSAGES/django.po index 83d3d48..858aa3a 100644 --- a/huismus/locale/nl/LC_MESSAGES/django.po +++ b/huismus/locale/nl/LC_MESSAGES/django.po @@ -5,7 +5,7 @@ msgid "" msgstr "" "Project-Id-Version: huismus\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-11-13 08:06+0100\n" +"POT-Creation-Date: 2026-11-13 15:46+0100\n" "PO-Revision-Date: 2026-09-25 08:36+0200\n" "Last-Translator: \n" "Language-Team: \n" @@ -600,6 +600,9 @@ msgstr "" msgid "Updates" msgstr "" +msgid "De Microsoft Store werkt Huismus bij." +msgstr "" + msgid "Huismus werkt zichzelf bij." msgstr "" diff --git a/huismus/templates/business/updates.html b/huismus/templates/business/updates.html index 5c1d092..1f5ab34 100644 --- a/huismus/templates/business/updates.html +++ b/huismus/templates/business/updates.html @@ -13,6 +13,11 @@

{% translate "Updates" %}

+{% if store %} +
+

{% translate "De Microsoft Store werkt Huismus bij." %}

+
+{% else %} {% if problem %} {% endif %} @@ -53,4 +58,5 @@

{% blocktranslate with version=release.version %}Huismus {{ version }} is er
{% if enabled %}{% endif %}
+{% endif %} {% endblock %} diff --git a/tests/desktop/test_store.py b/tests/desktop/test_store.py new file mode 100644 index 0000000..73a9373 --- /dev/null +++ b/tests/desktop/test_store.py @@ -0,0 +1,235 @@ +# SPDX-FileCopyrightText: 2026 Huismus contributors +# SPDX-License-Identifier: AGPL-3.0-or-later +"""The Windows app from the Microsoft Store (SPEC.md M11, "Microsoft Store"; +huismus-intern/besluiten.md, 2026-11-13): its package manifest +(desktop/msix.py), knowing it runs from the package (desktop/windows.py), +and leaving updates to the Store (huismus/core/updates.py). The package +itself is built and installed on a Windows runner (release.yml).""" + +import xml.etree.ElementTree as ET +from pathlib import Path +from unittest.mock import patch + +import pytest +from django.urls import reverse + +from desktop import msix, windows +from huismus.core import updates + +IDENTITY = { + "MSIX_IDENTITY_NAME": "Voorbeeld.Huismus", + "MSIX_PUBLISHER": "CN=00000000-0000-0000-0000-000000000000", + "MSIX_PUBLISHER_DISPLAY_NAME": "Voorbeeld & Zn", +} +NS = { + "m": "http://schemas.microsoft.com/appx/manifest/foundation/windows10", + "rescap": "http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities", + "desktop6": "http://schemas.microsoft.com/appx/manifest/desktop/windows10/6", + "uap": "http://schemas.microsoft.com/appx/manifest/uap/windows10", +} +COMMAND = "huismus.core.management.commands.migrate_with_backup" + + +# -- The package -------------------------------------------------------------- + + +def _manifest(version="0.1.5"): + return ET.fromstring(msix.manifest(version, msix.identity(IDENTITY))) + + +def test_the_manifest_carries_the_reserved_identity_and_a_store_version(): + root = _manifest() + ident = root.find("m:Identity", NS) + assert ident.get("Name") == "Voorbeeld.Huismus" + assert ident.get("Publisher") == "CN=00000000-0000-0000-0000-000000000000" + # Four numbers, the last 0: the Store keeps that one for itself. + assert ident.get("Version") == "0.1.5.0" + assert ident.get("ProcessorArchitecture") == "x64" + # Escaped, not broken XML. + assert root.find("m:Properties/m:PublisherDisplayName", NS).text == "Voorbeeld & Zn" + assert root.find("m:Properties/m:DisplayName", NS).text == "Huismus" + + +def test_only_a_plain_version_becomes_a_package_version(): + for wrong in ("0.1", "0.1.5-beta", "v0.1.5", "0.1.5.1"): + with pytest.raises(ValueError): + msix.package_version(wrong) + + +def test_the_administration_stays_where_the_installer_keeps_it(): + """Virtualized, the package would keep new files in %APPDATA% in a + folder of its own, gone at uninstall and invisible to the installer's + Huismus (desktop/paths.py).""" + root = _manifest() + props = root.find("m:Properties", NS) + assert props.find("desktop6:FileSystemWriteVirtualization", NS).text == "disabled" + assert props.find("desktop6:RegistryWriteVirtualization", NS).text == "disabled" + capabilities = {c.get("Name") for c in root.findall("m:Capabilities/rescap:Capability", NS)} + assert capabilities == {"runFullTrust", "unvirtualizedResources"} + # Those two settings need Windows 10 2004 or later. + family = root.find("m:Dependencies/m:TargetDeviceFamily", NS) + assert family.get("MinVersion") == "10.0.19041.0" + + +def test_the_app_is_the_same_huismus_exe_as_the_installers(): + app = _manifest().find("m:Applications/m:Application", NS) + assert app.get("Executable") == "Huismus.exe" + assert app.get("EntryPoint") == "Windows.FullTrustApplication" + visual = app.find("uap:VisualElements", NS) + named = {visual.get("Square150x150Logo"), visual.get("Square44x44Logo")} + assert {name.removeprefix("Assets\\") for name in named} <= set(msix.LOGOS) + + +def test_without_the_identity_the_build_stops_and_says_what_is_missing(monkeypatch, capsys): + for name in msix.IDENTITY_VARIABLES: + monkeypatch.delenv(name, raising=False) + monkeypatch.setenv("MSIX_IDENTITY_NAME", "Voorbeeld.Huismus") + + assert msix.main(["0.1.5", "unused"]) == 1 + assert "MSIX_PUBLISHER, MSIX_PUBLISHER_DISPLAY_NAME" in capsys.readouterr().err + + +def test_it_writes_the_manifest_and_every_logo_it_names(tmp_path): + from PIL import Image + + msix.write(tmp_path, "0.1.5", msix.identity(IDENTITY)) + + assert (tmp_path / "AppxManifest.xml").read_text(encoding="utf-8").startswith(" Date: Fri, 13 Nov 2026 15:59:25 +0100 Subject: [PATCH 2/3] build(windows): pack the app as MSIX for the Microsoft Store MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit scripts/build-msix.sh wraps dist/Huismus in a package with the identity Partner Center reserved (repository secrets). release.yml builds it with the Windows app, installs a copy signed with a throwaway certificate, runs --check inside the package and keeps it as the windows-store artifact. File system virtualization is off, so the administration stays in %APPDATA%\Huismus for both versions. Playbook: n.v.t. — alleen voor de Windows-app van Huismus Co-Authored-By: Claude Opus 5.5 --- .github/workflows/release.yml | 54 +++++++++ SPEC.md | 1 + desktop/msix.py | 154 +++++++++++++++++++++++++ docs/desktop.md | 52 +++++++++ scripts/build-msix.sh | 28 +++++ tests/desktop/test_release_workflow.py | 15 +++ 6 files changed, 304 insertions(+) create mode 100644 desktop/msix.py create mode 100755 scripts/build-msix.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f615d97..9a523a9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -278,6 +278,60 @@ jobs: retention-days: 3 if-no-files-found: error + - name: Pack the same app for the Microsoft Store + # The identity Partner Center reserved; repository secrets, as the + # display name is a person's name and a run's log is public; secrets + # are masked there (desktop/msix.py, docs/desktop.md). + env: + MSIX_IDENTITY_NAME: ${{ secrets.MSIX_IDENTITY_NAME }} + MSIX_PUBLISHER: ${{ secrets.MSIX_PUBLISHER }} + MSIX_PUBLISHER_DISPLAY_NAME: ${{ secrets.MSIX_PUBLISHER_DISPLAY_NAME }} + run: ./scripts/build-msix.sh + + - name: Install the Store package and check it on an empty administration + # Windows installs only a signed package, so a copy gets a throwaway + # certificate with the Store's publisher, trusted on this runner only; + # the Store signs the unsigned one itself. Started through its alias + # it runs inside the package, so the check also sees that it knows + # (the log line) and that its log reaches the real %LOCALAPPDATA%, + # not a folder of the package's own (FileSystemWriteVirtualization). + shell: pwsh + env: + MSIX_PUBLISHER: ${{ secrets.MSIX_PUBLISHER }} + run: | + $ErrorActionPreference = 'Stop' + $sdk = (Get-ChildItem "${env:ProgramFiles(x86)}\Windows Kits\10\bin\10.*\x64" | Sort-Object { [version]$_.Parent.Name } | Select-Object -Last 1).FullName + $cert = New-SelfSignedCertificate -Type Custom -Subject $env:MSIX_PUBLISHER -KeyUsage DigitalSignature ` + -CertStoreLocation Cert:\CurrentUser\My ` + -TextExtension @('2.5.29.37={text}1.3.6.1.5.5.7.3.3', '2.5.29.19={text}') + $password = ConvertTo-SecureString -String ([guid]::NewGuid()) -Force -AsPlainText + Export-PfxCertificate -Cert $cert -FilePath "$env:RUNNER_TEMP\check.pfx" -Password $password | Out-Null + Export-Certificate -Cert $cert -FilePath "$env:RUNNER_TEMP\check.cer" | Out-Null + Import-Certificate -FilePath "$env:RUNNER_TEMP\check.cer" -CertStoreLocation Cert:\LocalMachine\TrustedPeople | Out-Null + $signed = "$env:RUNNER_TEMP\Huismus-check.msix" + Copy-Item "dist\Huismus-$env:version-windows.msix" $signed + $plain = [Runtime.InteropServices.Marshal]::PtrToStringBSTR([Runtime.InteropServices.Marshal]::SecureStringToBSTR($password)) + & "$sdk\signtool.exe" sign /fd SHA256 /f "$env:RUNNER_TEMP\check.pfx" /p $plain $signed + if ($LASTEXITCODE) { exit 1 } + Add-AppxPackage -Path $signed + $log = "$env:LOCALAPPDATA\Huismus\huismus.log" + Remove-Item $log -ErrorAction SilentlyContinue + $env:HUISMUS_DATA_DIR = "$env:RUNNER_TEMP\huismus-store-check" + $run = Start-Process -FilePath "$env:LOCALAPPDATA\Microsoft\WindowsApps\Huismus.exe" -ArgumentList '--check' -Wait -PassThru + if ($run.ExitCode -ne 0 -or -not (Select-String -Path $log -Pattern 'Microsoft Store' -Quiet)) { + Get-Content $log -ErrorAction SilentlyContinue + exit 1 + } + + - uses: actions/upload-artifact@v4 + # For the submission in Partner Center (docs/desktop.md). Not named + # *-app: the publish job leaves it off the GitHub release. + with: + name: windows-store + path: dist/Huismus-${{ env.version }}-windows.msix + retention-days: 30 + if-no-files-found: error + macos-window-test: # The app as a user gets it, with its window (SPEC.md M11, "Release test # in the window"; scripts/window-test-mac.sh): installed from the .dmg, diff --git a/SPEC.md b/SPEC.md index 9cee6aa..919bfd3 100644 --- a/SPEC.md +++ b/SPEC.md @@ -202,6 +202,7 @@ Beside the Docker/Pi route, not replacing it: Huismus as an ordinary desktop app - **First start is the onboarding**: business details, then importing the Moneybird export (section 1) or skipping it, then the dashboard with its checklist (M8). Setting a password is offered there too: the done page of the step-by-step start (M13) links to Wachtwoord, in the desktop app only. - **Signing.** Windows: signed for free through SignPath Foundation (decided 2026-11-12; until then not signed, as decided 2026-10-27), which signs open source projects with a certificate in its own name and asks for a code signing policy on the site (Meebouwen › Ondertekening, linked from the foot of every page) and a manual approval of every release by the maintainer. Signing the installer is enough: SmartScreen only checks the downloaded file. The workflow follows once SignPath has accepted the project and set it up. macOS: signed with an Apple Developer ID and notarized by Apple (decided 2026-11-12, the owner pays the € 99 a year), because the unsigned app needs a detour through System Settings that many users give up on. `release.yml` signs and notarizes once the owner has run `scripts/mac-ondertekenen.sh` (`docs/desktop.md`, "Ondertekenen op de Mac"); until then the macOS app carries only a free ad-hoc signature, without which it will not start on Apple Silicon. The download page explains, in Dutch and English, the one-time step on first opening: on macOS System Settings → Privacy & Security → "Open Anyway" (the step links straight to that screen), on Windows "More info" → "Run anyway". After the first notarized release the macOS step goes away. - **Updates.** The app checks GitHub Releases for a new version, downloads it in the background and installs it by itself the next time it opens, before the user can work (the owner's decision, 2026-11-04; `huismus-intern/besluiten.md`); a button in Settings → Updates updates right away, and "Nu zoeken" there looks for a new version right away instead of waiting for the daily look (on Docker too; the owner's request, 2026-11-05). A version that fails to install automatically is tried once; after that only the button tries it again. The check only fetches the public release list and can be turned off in Settings. Before an update is applied the app makes a backup (`manage.py backup`, M7); the migrations run after. If they fail, the app says so and offers to restore that backup. After a new version, in the app and on Docker alike, the overview shows a blue notice "Huismus is bijgewerkt naar ." with a link to Hulp → Wat is er nieuw and a close button, until it is closed or that page is opened; the last version seen is kept in the update status file next to the database, not in a backup. A new administration and the sample data get no notice; an administration from before the notice gets it once (issue #149; `huismus-intern/besluiten.md`, 2026-11-12). Built 2026-10-29 for macOS (choices: `huismus-intern/besluiten.md`; how it works: `docs/desktop.md`): a release offers `Huismus--macos.dmg`, which `release.yml` builds on a version tag on an Apple Silicon runner and checks with `--check` before it is published (the tag must equal the version in `pyproject.toml`; the app needs macOS 15 or later, the Homebrew Pango it carries is built for that); the download must match the SHA-256 GitHub lists for it; the app replaces itself through a small script after it has closed. Built 2026-10-29 for Windows (choices: `huismus-intern/besluiten.md`, "Windows-app: bouwkeuzes"): a release offers `Huismus--windows.exe` as well, an Inno Setup installer that installs per user in `%LOCALAPPDATA%\Programs\Huismus` without asking for administrator rights; on Windows the app looks only for that file, and updating runs it without any window from a small PowerShell script after the app has closed. `release.yml` publishes a release only once both apps are built and checked (on Windows the installed app) and the release test in the window passed on both; a trial build by hand (workflow_dispatch) builds and tests both apps with a version one patch above the latest release, and publishes nothing (2026-11-13; before, it built only Windows). Windows 10 or later, x64 only; WebView2 is not installed along: when it is missing, the app asks in a Windows message whether to download it from Microsoft, then closes. Opened a second time on Windows, the app brings the open window forward. The app carries its own root certificates (certifi) for HTTPS, so the update check and "Hoe gebruik je Huismus?" also work on a Mac without Homebrew; `--check` checks that (2026-11-02). +- **Microsoft Store** (the owner registered a free developer account and reserved the name on 2026-11-13; choices: `huismus-intern/besluiten.md`, 2026-11-13; how it works: `docs/desktop.md`, "Microsoft Store"). The same `Huismus.exe` also goes to the Microsoft Store as an MSIX package, which Microsoft signs, beside the installer on GitHub Releases. `release.yml` builds it with the Windows app (`scripts/build-msix.sh`, manifest from `desktop/msix.py`, the reserved identity from repository secrets), installs a copy signed with a throwaway certificate and runs `--check` inside the package; the package is kept with the run, not on the release, and submitted by hand in Partner Center. The first submission is the owner's to make or approve. The app knows it runs from the package and leaves updating to the Store: it never looks at GitHub, downloads or installs a version, ignores and clears what the installer left in the update status, and Settings → Updates says only "De Microsoft Store werkt Huismus bij." (card without on/off). When a new version from the Store brings migrations an existing administration lacks, the app makes a backup before them, as before its own updates, and failing migrations offer it back. The manifest turns off file system virtualization (`unvirtualizedResources`), so the administration stays in `%APPDATA%\Huismus` for both versions and uninstalling keeps it; Windows 10 2004 or later. Help, download page and "Wat is er nieuw" follow once the Store page is live. - **Backups.** Once a day, the app writes a backup archive to a folder the user picks once (iCloud Drive, OneDrive, Dropbox, a USB drive). The live database never goes into a synced folder, only the archives. The dashboard shows when the last backup was, and warns after a failed backup (folder unreachable or full) and after 7 days without one. Restoring is a button in the app, on top of `manage.py restore`: it first backs up the current state, and the app serves no pages while it restores. Built 2026-10-29 (choices: `huismus-intern/besluiten.md`; how it works: `docs/desktop.md`): the folder and the status belong to the computer and are not in a backup; a backup from a newer version is refused. Since M15 step 2 a backup can also go, encrypted, straight to Dropbox or WebDAV. - **Release test in the window** (proposed 2026-11-07; the owner decided the same day to build it, and a failing test stops the release; issue #117). `--check` starts everything except the window, from the built app. A further job in `release.yml` tests what a user gets, on the same GitHub runners (a fresh virtual machine per run, nothing to rent). macOS: install from the `.dmg` as a user would. Windows: the app the installer put in place. Then open the app with its window on an empty administration, walk through the onboarding, make and issue one invoice, download its PDF, and keep a screenshot of each step with the release run. On Windows Playwright drives the app's own WebView2 window, with remote debugging switched on for the test only. On macOS Playwright cannot attach to a WKWebView, so it drives the same server while the test checks from a screenshot that the window opened. The test needs the window's token without that token reaching other programs outside the test. It also updates from the previous release's download and checks that the data stayed and a backup was made first. Together this replaces those steps of the check by hand in *Done when*. Built 2026-11-13 (choices: `huismus-intern/besluiten.md`; how it works: `docs/desktop.md`, "Een release maken"): jobs `macos-window-test` and `windows-window-test` run `scripts/window-test-mac.sh` and `scripts/window-test-windows.sh` with `scripts/window_test.py`, and `publish` needs both. The token: on macOS the test makes one and hands it to that one app process (`HUISMUS_RELEASE_TEST_TOKEN`, only one of 32 characters or more counts, taken out of the app's environment at once, masked in the run's log, never in a screenshot); on Windows the test needs none, and only its own environment switches remote debugging on, on 127.0.0.1 (`WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS`; the app never sets it). The update: the previous release, installed from its own download, gets records from its own `demo_data` (its source at that tag), and the new build is put in place as a version it downloaded and checked; it then installs it at its next start through its own path. Afterwards each kind and status of document, and the expenses, have the same count and total to the cent as before, so does the backup from before the update, and the overview says "Huismus is bijgewerkt naar .". Not tested there: looking the release up at GitHub and downloading it (the release is not online yet); that, on one platform, stays a check by hand at each release. The warnings for an unsigned app (Gatekeeper, SmartScreen) only appear for a real download and stay a check by hand. No paid service: browser-testing services hardly test native desktop apps, and desktop test suites (Ranorex, TestComplete, Squish) are too heavy for a hobby project. - **What it gives up.** The app can only be reached while the laptop is open: no access from a phone, so no PWA (M9). Decided by the owner on 2026-11-13 (`huismus-intern/besluiten.md`, issue #143): a later step links a phone over the user's own Wi-Fi with a QR code, never from the internet, and only after `review-veiligheid` has judged it. One installation per administration (invariant 2): moving between laptop and Pi goes through backup and restore, never two running copies of the same data. diff --git a/desktop/msix.py b/desktop/msix.py new file mode 100644 index 0000000..2841ba0 --- /dev/null +++ b/desktop/msix.py @@ -0,0 +1,154 @@ +# SPDX-FileCopyrightText: 2026 Huismus contributors +# SPDX-License-Identifier: AGPL-3.0-or-later +"""The Microsoft Store version of the Windows app (SPEC.md M11, "Microsoft +Store"): the package manifest and the logos that MakeAppx packs around the +folder PyInstaller made. scripts/build-msix.sh runs this: + + python -m desktop.msix + +The identity is the one Partner Center reserved for Huismus. It comes from +the environment (in CI the repository secrets MSIX_IDENTITY_NAME, +MSIX_PUBLISHER and MSIX_PUBLISHER_DISPLAY_NAME), because the display name +is a person's name and stays out of this repository and the public run +logs (CLAUDE.md, "Privé"). + +Same Huismus.exe as the installer. Run from the package it notices so +(desktop/windows.py, packaged) and leaves updating to the Store. +""" + +import os +import re +import sys +from pathlib import Path +from xml.sax.saxutils import escape, quoteattr + +IDENTITY_VARIABLES = ("MSIX_IDENTITY_NAME", "MSIX_PUBLISHER", "MSIX_PUBLISHER_DISPLAY_NAME") +ICON = Path(__file__).resolve().parent.parent / "huismus/core/static/core/icons/icon-512.png" +# Name in the package → size in pixels. The three the manifest names. +LOGOS = { + "StoreLogo.png": 50, + "Square44x44Logo.png": 44, + "Square150x150Logo.png": 150, +} + +# FileSystemWriteVirtualization off: without it, Windows would keep every +# file Huismus creates in %APPDATA% in a folder of the package's own, which +# goes when the app is uninstalled, and which the installer's Huismus does +# not see. Off, the administration stays in %APPDATA%\Huismus, as with the +# installer (desktop/paths.py). Needs the restricted capability +# unvirtualizedResources (Windows 10 2004, build 19041, and later), which +# the Store submission asks a reason for (docs/desktop.md). +# +# The execution alias makes Huismus.exe startable from a command line in the +# package's context, which is how release.yml runs `Huismus.exe --check` on +# the installed package. +MANIFEST = """ + + + + Huismus + {publisher_display_name} + Assets\\StoreLogo.png + disabled + disabled + + + + + + + + + + + + + + + + + + + + + + + + + +""" + + +class MissingIdentity(Exception): + pass + + +def package_version(version: str) -> str: + """pyproject's 0.1.5 as the Store wants it: four numbers, the last 0 + (the Store keeps that one for itself).""" + if not re.fullmatch(r"\d+\.\d+\.\d+", version): + raise ValueError(f"Geen versie als 0.1.5: {version!r}") + return f"{version}.0" + + +def identity(environ=os.environ) -> dict: + missing = [name for name in IDENTITY_VARIABLES if not environ.get(name)] + if missing: + raise MissingIdentity(", ".join(missing)) + return { + "name": environ["MSIX_IDENTITY_NAME"], + "publisher": environ["MSIX_PUBLISHER"], + "publisher_display_name": environ["MSIX_PUBLISHER_DISPLAY_NAME"], + } + + +def manifest(version: str, ident: dict) -> str: + return MANIFEST.format( + name=quoteattr(ident["name"]), + publisher=quoteattr(ident["publisher"]), + version=quoteattr(package_version(version)), + publisher_display_name=escape(ident["publisher_display_name"]), + ) + + +def write(folder: Path, version: str, ident: dict) -> None: + """AppxManifest.xml and Assets/ in folder, next to Huismus.exe.""" + from PIL import Image + + (folder / "AppxManifest.xml").write_text(manifest(version, ident), encoding="utf-8") + assets = folder / "Assets" + assets.mkdir(exist_ok=True) + with Image.open(ICON) as icon: + for name, size in LOGOS.items(): + icon.resize((size, size), Image.Resampling.LANCZOS).save(assets / name) + + +def main(argv: list[str]) -> int: + version, folder = argv + try: + ident = identity() + except MissingIdentity as missing: + print(f"Ontbreekt in de omgeving: {missing} (docs/desktop.md)", file=sys.stderr) # noqa: T201 + return 1 + write(Path(folder), version, ident) + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/docs/desktop.md b/docs/desktop.md index 2da683e..8f25a40 100644 --- a/docs/desktop.md +++ b/docs/desktop.md @@ -212,6 +212,58 @@ installeert heeft geen MSYS2 nodig. Staat MSYS2 ergens anders dan `C:\msys64`, zet dan `PANGO_BIN` op zijn map `ucrt64\bin`. Controleren gaat net als op de Mac: `HUISMUS_DATA_DIR=... dist/Huismus/Huismus.exe --check`. +### Microsoft Store + +Dezelfde `dist/Huismus` gaat ook als MSIX-pakket naar de Microsoft Store +(Store-ID `9NKWHBHSFMHN`). Microsoft ondertekent wat de Store uitlevert, dus +die versie heeft geen last van SmartScreen of Smart App Control. Na +`build-windows.sh`, met de Windows SDK: + +```bash +./scripts/build-msix.sh +``` + +Dat maakt `dist/Huismus--windows.msix`, niet ondertekend: de Store +ondertekent hem zelf. Het manifest en de logo's komen uit `desktop/msix.py`. +De identiteit die Partner Center voor Huismus heeft vastgelegd, staat in drie +secrets van de repository (Settings → Secrets and variables → Actions), +omdat de naam van de uitgever een persoonsnaam is en de logs van een run +openbaar zijn: +`MSIX_IDENTITY_NAME`, `MSIX_PUBLISHER` (`CN=…`) en +`MSIX_PUBLISHER_DISPLAY_NAME`. Lokaal zet je ze in de omgeving. + +Wat anders is dan bij de installer: + +- **Bijwerken doet de Store.** `Huismus.exe` ziet zelf dat hij uit een pakket + draait (`desktop/windows.py`, `packaged`) en zet dan `HUISMUS_STORE`. + Huismus kijkt dan niet op GitHub, downloadt niets en installeert niets; + Instellingen → Updates zegt alleen dat de Store het doet. Wat de installer + in het statusbestand achterliet (een gedownloade versie, een mislukte + update), gaat bij de eerste start weg (`updates.start_from_store`). +- **Back-up vóór de migraties.** De Store vervangt de app terwijl Huismus + dicht is. Brengt de nieuwe versie migraties mee, dan maakt Huismus bij het + openen eerst een back-up, net als bij een eigen update. Mislukken de + migraties, dan biedt het scherm die back-up aan. +- **Dezelfde administratie.** Het manifest zet de virtualisatie van + `%APPDATA%` uit (`FileSystemWriteVirtualization`, met de beperkte + capability `unvirtualizedResources`). Zo blijft de administratie in + `%APPDATA%\Huismus`: wie overstapt van de installer houdt alles, en + verwijderen laat de administratie staan. Daarom is Windows 10 2004 het + minimum. + +`release.yml` bouwt het pakket in de job `windows-app`, ondertekent een kopie +met een wegwerpcertificaat, installeert die en draait `Huismus.exe --check` +via zijn alias, binnen het pakket. Het pakket staat 30 dagen als artifact +`windows-store` bij de run. Het komt niet op de GitHub-release: de updates +van de installer zoeken daar alleen de `.exe` en de `.dmg`. + +**Inzenden** gaat met de hand in Partner Center: Start submission, het +`.msix` uit het artifact uploaden, en bij `unvirtualizedResources` deze reden: +"The app stores the user's own bookkeeping records in %APPDATA%\Huismus, the +same place as our installer version, so users who move to the Store version +keep their data, and uninstalling never deletes their records." Elke versie +moet hoger zijn dan de vorige inzending. + Vanuit de broncode, zonder in te pakken: ```bash diff --git a/scripts/build-msix.sh b/scripts/build-msix.sh new file mode 100755 index 0000000..e5946e8 --- /dev/null +++ b/scripts/build-msix.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: 2026 Huismus contributors +# SPDX-License-Identifier: AGPL-3.0-or-later +# +# Packs the Windows app for the Microsoft Store (SPEC.md M11, "Microsoft +# Store"): dist/Huismus--windows.msix, from the dist/Huismus folder +# scripts/build-windows.sh made. Same Huismus.exe as the installer. +# +# Unsigned: the Store signs what it publishes. To install it yourself, sign a +# copy (release.yml does that with a throwaway certificate to check it). +# Needs the Windows SDK's makeappx.exe (on GitHub's Windows runners), and the +# identity Partner Center reserved in MSIX_IDENTITY_NAME, MSIX_PUBLISHER and +# MSIX_PUBLISHER_DISPLAY_NAME (desktop/msix.py, docs/desktop.md). +set -euo pipefail +cd "$(dirname "$0")/.." + +version=$(sed -n 's/^version = "\(.*\)"$/\1/p' pyproject.toml | head -n 1) +sdk=$(ls -d "/c/Program Files (x86)/Windows Kits/10/bin/10."*/x64 | sort -V | tail -n 1) +makeappx="${MAKEAPPX:-$sdk/makeappx.exe}" + +test -f dist/Huismus/Huismus.exe || { echo "Eerst scripts/build-windows.sh" >&2; exit 1; } +rm -rf build/msix "dist/Huismus-$version-windows.msix" +mkdir -p build +cp -r dist/Huismus build/msix +uv run python -m desktop.msix "$version" build/msix + +"$makeappx" pack //o //d "$(cygpath -w build/msix)" //p "$(cygpath -w "dist/Huismus-$version-windows.msix")" +echo "Klaar: dist/Huismus-$version-windows.msix" diff --git a/tests/desktop/test_release_workflow.py b/tests/desktop/test_release_workflow.py index fcba3f3..6070314 100644 --- a/tests/desktop/test_release_workflow.py +++ b/tests/desktop/test_release_workflow.py @@ -148,3 +148,18 @@ def test_the_publish_job_downloads_only_the_two_apps(): # v0.1.1: the Docker build record on the same run made the download fail. assert 'pattern: "*-app"' in PUBLISH_JOB assert "name: macos-app\n" in MAC_JOB and "name: windows-app\n" in WINDOWS_JOB + + +def test_the_store_package_is_built_and_installed_but_not_on_the_release(): + """The Microsoft Store version (desktop/msix.py) goes to Partner Center + by hand; the GitHub release keeps the two files the updates look for.""" + assert "./scripts/build-msix.sh" in WINDOWS_JOB + assert "Add-AppxPackage" in WINDOWS_JOB and "--check" in WINDOWS_JOB + assert "name: windows-store" in WINDOWS_JOB + assert ".msix" not in PUBLISH_JOB + + +def test_the_store_identity_comes_from_repository_secrets(): + # The publisher's display name is a person's name (CLAUDE.md, "Privé"). + for name in ("MSIX_IDENTITY_NAME", "MSIX_PUBLISHER", "MSIX_PUBLISHER_DISPLAY_NAME"): + assert f"{name}: ${{{{ secrets.{name} }}}}" in WINDOWS_JOB From 4eb887a36a9f01f8e9c612eb0da27a6cb0cb9c99 Mon Sep 17 00:00:00 2001 From: Arne Date: Fri, 13 Nov 2026 16:08:34 +0100 Subject: [PATCH 3/3] fix(ci): install the Store package from Windows PowerShell MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PowerShell 7 can't load the Appx module, so Add-AppxPackage failed. Playbook: n.v.t. — alleen voor de Windows-app van Huismus Co-Authored-By: Claude Opus 5.5 --- .github/workflows/release.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9a523a9..a981895 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -295,7 +295,8 @@ jobs: # it runs inside the package, so the check also sees that it knows # (the log line) and that its log reaches the real %LOCALAPPDATA%, # not a folder of the package's own (FileSystemWriteVirtualization). - shell: pwsh + # Windows PowerShell: PowerShell 7 can't load the Appx module. + shell: powershell env: MSIX_PUBLISHER: ${{ secrets.MSIX_PUBLISHER }} run: |