diff --git a/CHANGELOG.md b/CHANGELOG.md index 92008cb..77eef8b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ and the project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ## [Unreleased] ### Added +- **Machine-wide MSI for IT departments**: every release now also has `Rotula-X.Y.Z-win-x64.msi`, signed like the setup program. It installs to Program Files for every user with a Start menu entry and the `.rotula` association, upgrades older versions in place, and is made for silent deployment with Intune, Group Policy or Configuration Manager. The setup program can now also install for all users silently with `/ALLUSERS`. Silent install and uninstall commands, exit codes, Intune detection rules and Group Policy steps are in the new [docs/deployment.md](docs/deployment.md). +- **Settings your organization can manage**: administrators can fix the language and switch the update check on or off for everyone, suggest a default printer, keep add-ons in the user's own folder from loading, and switch printing or database access of the MCP server off, through Group Policy or Intune. Administrative templates (English and Spanish) are in `packaging/policies`. A setting fixed this way is greyed out in Rotula with "Managed by your organization". +- **Optional update check** (Help > Check for updates once a day): off unless you switch it on. Then Rotula asks GitHub once a day, with one anonymous request that carries nothing about you or your PC, whether a newer version exists, and shows a notice with a link to the release page. It never downloads or installs anything. +- **winget**: the manifests use schema 1.10, offer the MSI for `--scope machine` next to the per-user installer, and have a Spanish (es-MX) description. The submission steps are in docs/releasing.md. - **Print station** for the warehouse floor: a locked, print-only screen started with `Rotula.exe --station ` (without a path it uses `%ProgramData%\Rotula\station.json`) or from **File > Open print station**. Labels show as large tiles with thumbnails and a search box; scanning a label's code opens it. Its questions are large fields that take keyboard-wedge scanner input: Enter or Tab moves to the next one, the last scan can print at once, and the label's own rules (required, length, pattern) are checked before anything is sent. Printing goes through the same print path as the Print dialog with the printer status check, and the result is shown in large plain words (ready, held back with the reason, printed and verified, not confirmed); the questions then reset for the next item, keeping the answers the station is told to keep. A `station.json` sets the labels and their scan codes, the printer of each label or a default, copy limits, whether the label's data may be printed, and an exit PIN stored only as a salted PBKDF2 hash (made with **File > Create print station PIN** or `Rotula.exe --station-pin`); five wrong PINs lock the PIN box for 30 seconds. Every setting is checked and a mistake is explained with the setting and line. See [docs/print-station.md](docs/print-station.md). - **Printer status before and after printing** (ZPL `~HS`). Before each job Rotula asks the printer for its status and does not send while it reports it is out of labels, its printhead is open, the ribbon is out, it is paused, too hot, in diagnostics mode or its buffer is full; the Print dialog says which and what to do. After the job it waits (up to 30 seconds) until the printer reports it finished, then says "printed and verified", names a fault that appeared after the job (the labels still count as possibly printed, so no serial number is reused), or says it could not confirm. A printer that cannot answer never blocks printing. On by default for network printers and off for Windows printers, switchable in the Print dialog; "Check printer status" shows the decoded answer at any time. The MCP server has a matching `get_printer_status` tool. - **A gallery of eight showcase labels** in the README (GS1-128 pallet, parcel, food, wine, lab specimen, asset tag, shelf tags with data merge, print conditions). Their `.rotula` files and the CSV data are in `docs/showcase`, and the pictures are rendered with Rotula's own PNG export by a test that only runs when `ROTULA_RENDER_SHOWCASE=1` is set. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c8b13b0..7bd1af3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -10,8 +10,11 @@ in [SECURITY.md](SECURITY.md), not a public issue. These are product rules; changes that break them will not be merged. -- **Offline-first**: no network calls, no telemetry, no update checks, no analytics. Printing to the printer address the user - entered is the only network traffic. +- **Offline-first**: no telemetry, no analytics, and no network traffic unless the user asks for it: printing to the printer + address the user entered and the databases the user connects. The one exception is the **opt-in update check**: it sends + nothing unless the user (Help menu) or an administrator (policy) explicitly switches it on, and then it is at most one + anonymous `GET` of the public GitHub "latest release" API a day, with nothing about the user or the computer in it. It never + downloads or installs anything. Do not add other network calls, and do not add anything to that request. - **Light theme only.** - **Spanish and English** through `.resx` resources (`src/Rotula.App/Resources/Strings.en.resx` and `Strings.es.resx`). Every new user-visible string needs both. Core returns message keys, not text. diff --git a/README.es.md b/README.es.md index 1f40bae..581e2b2 100644 --- a/README.es.md +++ b/README.es.md @@ -75,8 +75,12 @@ Siempre la versión más reciente: [github.com/P4Software/Rotula/releases/latest |---|---|---| | **Instalador** | `Rotula--win-x64-setup.exe` | Instalación por usuario, acceso en el menú Inicio e icono de escritorio opcional. Firmado digitalmente | | **Portátil** | `Rotula--win-x64-portable.zip` | Descomprime en cualquier carpeta y haz doble clic en `Rotula.App.exe`. No instala nada. No necesita .NET | +| **MSI (para TI)** | `Rotula--win-x64.msi` | Instalación para todo el equipo en Archivos de programa, para todos los usuarios, con Intune, directiva de grupo o Configuration Manager. Firmado. Consulta la [instalación en empresas](docs/deployment.md) (en inglés) | | **winget** | `winget install P4Software.Rotula` | Próximamente. El manifiesto del paquete está preparado, pero aún no fue aceptado en el catálogo de winget | +Para instalar Rotula en toda una organización (instalación silenciosa, el MSI, Intune, configuración por directiva de grupo): +[docs/deployment.md](docs/deployment.md) (en inglés). + Cada versión incluye además un archivo `SHA256SUMS.txt`.
@@ -212,8 +216,9 @@ El software de etiquetas ha significado casi siempre una licencia de pago, una c es un único programa de escritorio que hace el trabajo y no estorba. - **Gratis y con licencia MIT.** Úsalo en casa, en la oficina, en un almacén o dentro de un producto. Sin puestos ni planes. -- **Sin conexión por diseño.** Sin analítica, sin búsqueda de actualizaciones, sin sincronización en la nube. El único tráfico - de red que Rotula puede generar es la etiqueta que envías a la dirección de impresora que escribiste. +- **Sin conexión por diseño.** Sin analítica, sin telemetría, sin sincronización en la nube. El único tráfico de red que + Rotula puede generar es la etiqueta que envías a la dirección de impresora que escribiste, las bases de datos que conectas + y, solo si la activas, una búsqueda de actualizaciones anónima al día. - **ZPL honesto.** La misma etiqueta con los mismos datos siempre da ZPL idéntico byte a byte, en ASCII puro. - **Errores que puedes resolver.** Un dígito verificador EAN-13 inválido o un carácter que no existe en Code 39 se reporta en lenguaje sencillo, con lo que debes hacer a continuación. @@ -452,9 +457,12 @@ puedes cambiarlo en cualquier momento desde **Ver > Idioma**, sin reiniciar. El ## Privacidad -Rotula funciona totalmente sin conexión. No tiene telemetría, ni analítica, ni búsqueda de actualizaciones, ni cuentas, y no -hace llamadas de red. El único tráfico de red que puede generar es la etiqueta que envías a la dirección de impresora de red -que indicaste. Tus preferencias (idioma, unidades, archivos recientes, última impresora) son un pequeño archivo JSON en +Rotula funciona totalmente sin conexión. No tiene telemetría, ni analítica, ni cuentas, y no hace llamadas de red por su +cuenta. El único tráfico de red que puede generar es la etiqueta que envías a la dirección de impresora de red que indicaste +y las bases de datos a las que te conectas. **Ayuda > Buscar actualizaciones una vez al día** está desactivado hasta que lo +activas: entonces Rotula pregunta a GitHub una vez al día cuál es la versión más reciente (una sola solicitud anónima, no se +envía nada sobre ti ni sobre tu PC) y muestra un enlace cuando hay una más nueva. Nunca descarga ni instala nada. Un +administrador puede activarla o desactivarla para todos ([instalación en empresas](docs/deployment.md), en inglés). Tus preferencias (idioma, unidades, archivos recientes, última impresora) son un pequeño archivo JSON en `%AppData%\Rotula`. Si el programa falla, escribe un registro en `%AppData%\Rotula\rotula.log` en tu PC y no lo envía a ningún lado. --- @@ -525,7 +533,7 @@ flowchart LR | `src/Rotula.Mcp` | El servidor MCP (un ejecutable aparte) | | `src/Rotula.App` | La aplicación WPF | | `tests/` | Pruebas xUnit y los archivos ZPL de referencia | -| `packaging/` | Script del instalador y plantillas del manifiesto de winget | +| `packaging/` | Script del instalador, proyecto del MSI, plantillas de directiva de grupo y plantillas del manifiesto de winget | | `docs/` | [Cobertura ZPL](docs/zpl-coverage.md), [catálogo de medios](docs/stock-catalog.md), [proveedores de datos](docs/data-providers.md), [servidor MCP](docs/mcp.md), [impresión](docs/printing.md), [publicación de versiones](docs/releasing.md), [decisiones de diseño](docs/decisions.md) (en inglés) | --- diff --git a/README.md b/README.md index 3235bfc..73e5549 100644 --- a/README.md +++ b/README.md @@ -75,8 +75,11 @@ Always the newest version: [github.com/P4Software/Rotula/releases/latest](https: |---|---|---| | **Installer** | `Rotula--win-x64-setup.exe` | Per-user install, Start menu entry, optional desktop icon. Code-signed | | **Portable** | `Rotula--win-x64-portable.zip` | Unzip anywhere and double-click `Rotula.App.exe`. Nothing is installed. No .NET needed | +| **MSI (for IT)** | `Rotula--win-x64.msi` | Machine-wide install to Program Files for every user, for Intune, Group Policy or Configuration Manager. Code-signed. See [deployment](docs/deployment.md) | | **winget** | `winget install P4Software.Rotula` | Coming soon. The package manifest is prepared but not yet accepted into the winget catalog | +Installing Rotula for a whole organization (silent install, the MSI, Intune, Group Policy settings): [docs/deployment.md](docs/deployment.md). + Every release also carries a `SHA256SUMS.txt` file.
@@ -209,8 +212,9 @@ Label software has long meant a paid license, an account, a subscription or a se does the job and stays out of the way. - **Free and MIT licensed.** Use it at home, at work, in a warehouse or inside a product. No seats, no tiers. -- **Offline by design.** No analytics, no update checks, no cloud sync. The only network traffic Rotula can produce is the - label you send to the printer address you typed. +- **Offline by design.** No analytics, no telemetry, no cloud sync. The only network traffic Rotula can produce is the + label you send to the printer address you typed, the databases you connect, and, only if you switch it on, one anonymous + daily update check. - **Honest ZPL.** The same label and data always give byte-identical ZPL, in plain ASCII. - **Errors you can act on.** An invalid EAN-13 check digit or a Code 39 character that does not exist is reported in plain language, with what to do next. @@ -441,8 +445,12 @@ switch at any time from **View > Language**, with no restart. The installer is a ## Privacy -Rotula works fully offline. It has no telemetry, no analytics, no update checks, no account and makes no network calls. The -only network traffic it can produce is the label you send to the network printer address you entered. Your settings +Rotula works fully offline. It has no telemetry, no analytics, no account and makes no network calls of its own. The +only network traffic it can produce is the label you send to the network printer address you entered and the databases you +connect to. **Help > Check for updates once a day** is off until you switch it on: then Rotula asks GitHub once a day which +version is the newest (one anonymous request, nothing about you or your PC is sent) and shows a link when there is a newer +one. It never downloads or installs anything. An administrator can switch the check on or off for everyone +([deployment](docs/deployment.md)). Your settings (language, units, recent files, last printer) are a small JSON file in `%AppData%\Rotula`. If the program ever fails, it writes a log to `%AppData%\Rotula\rotula.log` on your PC and sends it nowhere. @@ -514,7 +522,7 @@ flowchart LR | `src/Rotula.Mcp` | The MCP server (a separate executable) | | `src/Rotula.App` | The WPF application | | `tests/` | xUnit tests and the golden ZPL fixtures | -| `packaging/` | Installer script and winget manifest templates | +| `packaging/` | Installer script, MSI project, Group Policy templates and winget manifest templates | | `docs/` | [ZPL coverage](docs/zpl-coverage.md), [stock catalogue](docs/stock-catalog.md), [database providers](docs/data-providers.md), [MCP server](docs/mcp.md), [printing](docs/printing.md), [releasing](docs/releasing.md), [design decisions](docs/decisions.md) | --- diff --git a/SECURITY.md b/SECURITY.md index a57463d..484f95b 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -19,7 +19,8 @@ changelog unless you prefer otherwise. ## What is in scope Rotula is an offline desktop application. It makes no network calls except the printing you ask for (a TCP connection to the -printer address you enter). Reports of interest include: +printer address you enter), the databases you connect, and, only when you or your administrator switch it on, one anonymous +daily request to the GitHub "latest release" API (see [docs/deployment.md](docs/deployment.md#network-traffic-and-the-update-check)). Reports of interest include: - Crashes, hangs or memory exhaustion when opening a crafted `.rotula`, ZPL, CSV or Excel file. - Anything that causes Rotula to write outside the folders you chose, or to send data anywhere other than the printer you selected. diff --git a/THIRD-PARTY-NOTICES.md b/THIRD-PARTY-NOTICES.md index b09c1ce..578aee5 100644 --- a/THIRD-PARTY-NOTICES.md +++ b/THIRD-PARTY-NOTICES.md @@ -101,7 +101,8 @@ Specifications. | Newtonsoft.Json (via the test SDK) | 13.0.3 | MIT | | coverlet.collector | 6.0.4 | MIT | | dotnet-reportgenerator-globaltool (CI only, installed on demand) | latest | Apache-2.0 | -| Inno Setup (CI only, builds the installer) | 6.x | Inno Setup License (free, redistribution of the tool is not part of Rotula) | +| Inno Setup (release build machine, builds the per-user installer) | 6.x | Inno Setup License (free, redistribution of the tool is not part of Rotula) | +| WiX Toolset SDK (`WixToolset.Sdk`, release build machine, builds the MSI; nothing of it is inside the MSI or the app) | 5.0.2 | MS-RL (Microsoft Reciprocal License); the tool is not redistributed | ## Data diff --git a/docs/decisions.md b/docs/decisions.md index 796f281..8e98e8c 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -163,6 +163,56 @@ because the language is process-wide state. satellite resource assemblies and the SkiaSharp native library all load from the single file. The native library is unpacked to a temporary folder on first start, which is why the first launch can take a second or two longer. +### Machine-wide MSI (WiX Toolset), next to the Inno Setup installer +Approved by the owner (Glenn) as part of the enterprise deployment work: administrators need a per-machine MSI for Intune, +Group Policy software installation and Configuration Manager, which handle MSI packages natively (product code detection, +upgrades, return codes). The per-user Inno Setup installer stays the default for people who install Rotula themselves. +- **Tool**: WiX Toolset v5 (`WixToolset.Sdk/5.0.2`, MS-RL), restored by `dotnet build` on the build machine. It is a build-time + tool only: nothing of it is inside the MSI or the app, so the "permissive licences for what ships" rule is unaffected. It is + listed under "Build and test only" in THIRD-PARTY-NOTICES.md. The WiX project asks organizations that earn money from it to + pay its Open Source Maintenance Fee; **whether that applies to P4 Software is for the owner to check** before the first MSI + release. +- **Not in Rotula.sln**: the WiX build needs Windows Installer and only runs on Windows; the solution must keep building on any + runner. `packaging/msi/Rotula.Msi.wixproj` is built by `build-installer.ps1` from the stage folder after the programs are + signed, and the MSI is signed and verified like the setup exe. An unsigned MSI is renamed `-UNSIGNED` and never published. +- **Shape**: per-machine, `Program Files\Rotula`, advertised Start menu shortcut, `.rotula` association in HKLM (property + `ASSOCIATE=0` skips it), ARP links and icon, no Modify. No wizard pages (silent deployment is the use case); deploying it + means accepting `LICENSE-THIRD-PARTY.txt` for the users, as `docs/deployment.md` says. +- **Upgrades**: fixed UpgradeCode `{4B2C6076-5B82-411C-B751-EC4B70BEE9C6}` (never change it), a new ProductCode per build, + `MajorUpgrade` with same-version upgrades allowed (MSI versions are numeric, so 0.4.0 must be able to replace 0.4.0-rc1) and + downgrades refused. The UpgradeCode differs from the Inno AppId on purpose: they are two products, and a computer should + have only one of them. + +### Organization policies (Group Policy / Intune) +`Rotula.Files.Policy` reads `SOFTWARE\Policies\P4Software\Rotula` from HKLM and HKCU once at start-up, value by value with +the computer winning (`LayeredPolicySource` over two `RegistryPolicySource`s, behind `IPolicySource` so tests use a dictionary). +It lives in Rotula.Files because both the app and the MCP server need it. `RotulaPolicy.Read` ignores values it does not +understand, so a typo cannot force a wrong value. +- **What a policy may do**: choose a value for the user and lock it (language, update check), suggest a default (printer, not + locked), or take something away (add-ons from the user's folder; MCP printing and database access). It never switches a risky + feature on: the MCP policies only block, and the MCP settings file still has to allow printing and databases. +- **Locked settings in the UI**: the menu entries are disabled (the language commands' CanExecute, the update entry's + IsEnabled) and a disabled "Managed by your organization" line appears under them. The user's own value stays in + settings.json and comes back when the policy is removed; `AppSettings.LanguageIn` / `UpdateCheckIn` give the value in force. +- **Templates**: `packaging/policies/Rotula.admx` with en-US and es-ES ADML. A test checks that every registry value name of the + template is a `PolicyNames` constant (and the other way round) and that both ADML files define the same strings. + +### Update check: opt-in, anonymous, never installs +The owner approved an update check on the condition that it sends nothing unless explicitly enabled. Rules: +- Off by default. It runs only when the user ticks Help > Check for updates once a day, or the `UpdateCheck` policy is 1. The + policy at 0 forces it off and the user cannot switch it on. +- At most once a day (`UpdateSchedule.IsDue`, 24 hours; a last-check time in the future counts as due so a wrong clock cannot + stop it). The time is stored before asking, so an offline computer tries once a day, not at every start. +- One `GET https://api.github.com/repos/P4Software/Rotula/releases/latest`, User-Agent `Rotula` without a version, no cookies, + no identifiers; the Windows proxy with default credentials. Failures are silent. +- `tag_name` is parsed as SemVer (`SemanticVersion`, pre-release ordering per SemVer 2.0); drafts and pre-releases are ignored. + The link of the notice is built from the parsed version (`https://github.com/P4Software/Rotula/releases/tag/vX.Y.Z`), never + taken from the answer, and the launcher opens https addresses only. +- A newer version shows a non-modal strip under the toolbar ("View release", close). Closing it remembers that version so it + does not come back for it. Nothing is downloaded or installed, ever. +- Testable: version compare, schedule, parsing and the checker are UI-free (`Services/UpdateCheck.cs`); HTTP is behind + `IReleaseFeed`, and `GitHubReleaseFeed` takes a handler so a test sees the exact request. + ### Installer: Inno Setup, not MSIX An MSIX package must be signed with a certificate the machine trusts before Windows will install it, and the project has no signing certificate (and the CI has no secrets). An unsigned MSIX cannot be installed by double-clicking it, which @@ -174,9 +224,11 @@ documents. The script lives in `packaging/installer/Rotula.iss`; the CI installs certificate becomes available, signing both the exe and the installer is the only change needed. ### Winget -`packaging/winget/` holds templates for the version, installer and en-US locale manifests (schema 1.9.0). -`scripts/New-WingetManifest.ps1` fills version, installer URL, SHA-256 and date from the tag, and the release workflow attaches -the result as a zip. Submitting it to the winget-pkgs repository is a manual step because it needs a personal token. +`packaging/winget/` holds templates for the version, installer, en-US default locale and es-MX locale manifests (schema 1.10.0). +The installer manifest lists two installers: the Inno Setup exe (`Scope: user`, the default) and the MSI (`Scope: machine`, +`InstallerType: wix`, `ElevationRequirement: elevationRequired`, its ProductCode and the fixed UpgradeCode). +`scripts/New-WingetManifest.ps1` fills version, URLs, SHA-256 hashes, the MSI's ProductCode (read from the signed MSI) and the +date; `build-installer.ps1` runs it and zips the result. Submitting it to winget-pkgs is a manual step (docs/releasing.md). ### Coverage gate CI fails when line coverage of `Rotula.Core` drops below 80% (`scripts/check-coverage.ps1`, reading the Cobertura report from diff --git a/docs/deployment.md b/docs/deployment.md new file mode 100644 index 0000000..11b972c --- /dev/null +++ b/docs/deployment.md @@ -0,0 +1,252 @@ +# Deploying Rotula in an organization + +This page is for administrators who install Rotula on many computers: silent installs, the machine-wide MSI, Intune, +Group Policy and Configuration Manager, and the policy settings that fix Rotula's settings for users. + +## Which package to use + +Every release has two installers, both signed by **Barrdega Sistemas NA LLC** (check with `Get-AuthenticodeSignature`, see +[releasing.md](releasing.md#verifying-a-signature)) and listed in `SHA256SUMS.txt`: + +| Package | Installs to | Rights | Best for | +|---|---|---|---| +| `Rotula-X.Y.Z-win-x64.msi` | `C:\Program Files\Rotula`, for every user | Administrator (SYSTEM when deployed) | Intune, Group Policy software installation, Configuration Manager, any tool that deploys MSI packages | +| `Rotula-X.Y.Z-win-x64-setup.exe` (Inno Setup) | `%LocalAppData%\Programs\Rotula` for the current user, or `C:\Program Files\Rotula` with `/ALLUSERS` | None (per user) or administrator (`/ALLUSERS`) | People installing it themselves; per-user deployment scripts | + +Use **one** package per computer. The MSI and the Inno Setup installer are separate products in Apps & features (different +product codes), so a computer that has both shows Rotula twice. To move a computer from the per-user installer to the MSI, +uninstall the per-user copy first (command below). Settings, saved connections and counters live in `%AppData%\Rotula` and +are kept either way. + +Both packages install the same files: `Rotula.App.exe` (the designer), `Rotula.Mcp.exe` (the MCP server for AI assistants, +see [mcp.md](mcp.md)), the licence files, `docs\mcp.md`, the document icon and an empty `packs` folder for data source +add-ons. Both register `.rotula` files to open with Rotula and add a Start menu entry. Neither installs a service, a +scheduled task, a driver or a browser extension, and neither needs a reboot. + +**Licence terms.** Rotula is MIT, but it ships the SQL Server networking component under Microsoft's own licence terms. The +Inno Setup installer shows those terms and the user accepts them; the MSI has no wizard, so by deploying it you accept +`LICENSE-THIRD-PARTY.txt` (installed next to the program and attached to every release) on behalf of your users. See +[releasing.md](releasing.md#the-licence-page-and-the-sql-server-networking-component). + +## The MSI + +### Install + +``` +msiexec /i Rotula-X.Y.Z-win-x64.msi /qn /norestart /l*v "%TEMP%\Rotula-install.log" +``` + +`/qn` is completely silent; `/qb!` shows a small progress bar without a Cancel button. The command must run elevated +(Intune, Group Policy and Configuration Manager run it as SYSTEM). Public properties: + +| Property | Default | Meaning | +|---|---|---| +| `INSTALLFOLDER` | `C:\Program Files\Rotula` | Where the program goes, for example `INSTALLFOLDER="D:\Apps\Rotula"` | +| `ASSOCIATE` | `1` | `0` leaves the `.rotula` file association alone | + +Example: `msiexec /i Rotula-X.Y.Z-win-x64.msi /qn ASSOCIATE=0`. + +### Upgrade + +Install the newer MSI with the same command. The package has a fixed **UpgradeCode** +`{4B2C6076-5B82-411C-B751-EC4B70BEE9C6}` and a new **ProductCode** for every version (read it from the MSI with Orca, or from +the winget manifest of the release). The newer package removes the older one in the same transaction. Installing an older +version over a newer one is refused (error 1603 with "A newer version of Rotula is already installed" in the log). + +### Uninstall + +``` +msiexec /x {ProductCode} /qn /norestart +``` + +or `msiexec /x Rotula-X.Y.Z-win-x64.msi /qn`. Uninstalling removes the program folder, the Start menu entry and the +file association. It never touches users' settings in `%AppData%\Rotula`. + +### Exit codes + +| Code | Meaning | +|---|---| +| 0 | Success | +| 3010 | Success, a restart is required (Rotula itself never asks for one; another pending install can cause it) | +| 1602 | The user cancelled | +| 1603 | Fatal error: read the log (`/l*v`) | +| 1618 | Another installation is already running; try again later | +| 1619 | The MSI file could not be opened (wrong path, blocked download) | +| 1625 | Installation blocked by policy | +| 1925 | Not elevated: run the command as administrator | + +## The Inno Setup installer + +### Install silently + +``` +Rotula-X.Y.Z-win-x64-setup.exe /VERYSILENT /SUPPRESSMSGBOXES /NORESTART /SP- /LOG="%TEMP%\Rotula-setup.log" +``` + +That installs for the **current user** without administrator rights. For **all users** (Program Files, needs elevation) add +`/ALLUSERS`: + +``` +Rotula-X.Y.Z-win-x64-setup.exe /VERYSILENT /SUPPRESSMSGBOXES /NORESTART /SP- /ALLUSERS +``` + +Other switches: + +| Switch | Meaning | +|---|---| +| `/SILENT` | Progress window only, no questions (instead of `/VERYSILENT`) | +| `/DIR="D:\Apps\Rotula"` | Install folder | +| `/TASKS="associate"` | Choose the tasks; leave `associate` out to skip the `.rotula` association | +| `/MERGETASKS="desktopicon"` | Also add a desktop icon (off by default) | +| `/LANG=spanish` | Language of the installer itself (`english` or `spanish`) | +| `/CURRENTUSER` | Force a per-user install even when run elevated | + +The licence page is accepted automatically by `/VERYSILENT`, so the same note about the licence terms applies as for the MSI. + +### Uninstall silently + +The uninstaller is `unins000.exe` in the install folder: + +``` +"%LocalAppData%\Programs\Rotula\unins000.exe" /VERYSILENT /SUPPRESSMSGBOXES /NORESTART +"C:\Program Files\Rotula\unins000.exe" /VERYSILENT /SUPPRESSMSGBOXES /NORESTART +``` + +The first line is a per-user install, the second an `/ALLUSERS` one. The uninstall entry is +`{6B0E5C1A-3F7D-4C52-9A1E-2D8F4B7A9C31}_is1` under `...\CurrentVersion\Uninstall` (HKCU for per-user, HKLM for all users); +its `QuietUninstallString` value holds the exact command. + +### Exit codes + +| Code | Meaning | +|---|---| +| 0 | Success | +| 1 | Setup could not start | +| 2 | Cancelled before the installation began | +| 3 | Fatal error while preparing | +| 4 | Fatal error during the installation | +| 5 | Cancelled during the installation | +| 7 | Preparing to install failed (for example a file in use) | +| 8 | Preparing to install failed and a restart is needed first | + +## Microsoft Intune + +### As a Win32 app (recommended) + +1. Put `Rotula-X.Y.Z-win-x64.msi` alone in a folder and wrap it with the + [Microsoft Win32 Content Prep Tool](https://learn.microsoft.com/intune/intune-service/apps/apps-win32-prepare): + `IntuneWinAppUtil.exe -c -s Rotula-X.Y.Z-win-x64.msi -o `. +2. In the Intune admin center: **Apps > Windows > Add > Windows app (Win32)** and upload the `.intunewin` file. Intune + reads the name, publisher, version and ProductCode from the MSI and fills the install and uninstall commands: + - Install: `msiexec /i "Rotula-X.Y.Z-win-x64.msi" /qn /norestart` + - Uninstall: `msiexec /x {ProductCode} /qn /norestart` + - Install behavior: **System**. Device restart behavior: **Determine behavior based on return codes** (the defaults map + 0, 1707, 3010, 1641 and 1618 correctly). +3. **Detection rule**: choose *Manually configure detection rules > MSI*, with the ProductCode Intune filled in. To detect + "this version or newer" instead, use a *File* rule: path `%ProgramFiles%\Rotula`, file `Rotula.App.exe`, + *String (version)*, *Greater than or equal to* `X.Y.Z`. +4. For a later version, add it as a new app and set **Supersedence** to the previous one with *Uninstall previous version* + off: the MSI upgrades in place. + +The per-user Inno Setup installer can also be packaged as a Win32 app with install behavior **User**, the command +`Rotula-X.Y.Z-win-x64-setup.exe /VERYSILENT /SUPPRESSMSGBOXES /NORESTART /SP-`, and a file detection rule on +`%LocalAppData%\Programs\Rotula\Rotula.App.exe`. + +### As a line-of-business app + +**Apps > Windows > Add > Line-of-business app** accepts the MSI directly. It is simpler, but Win32 apps give you detection +rules, return codes and supersedence, so prefer Win32 when mixing with other app types. + +### Policy settings with Intune + +Either way below writes the same registry values as Group Policy, so Rotula behaves the same. + +- **Imported administrative templates** (recommended): **Devices > Configuration > Import ADMX**, upload + `packaging/policies/Rotula.admx` with `packaging/policies/en-US/Rotula.adml`. Then create a profile of type + **Templates > Imported Administrative templates** and set the policies under *P4 Software > Rotula*, in Computer + Configuration (for every user of the device) or User Configuration. +- **Custom OMA-URI (ADMX ingestion)**, for tenants without ADMX import: + 1. Ingest the template: OMA-URI `./Device/Vendor/MSFT/Policy/ConfigOperations/ADMXInstall/Rotula/Policy/RotulaAdmx`, data type + *String*, value: the whole content of `Rotula.admx`. + 2. Set a policy: OMA-URI `./Device/Vendor/MSFT/Policy/Config/Rotula~Policy~P4Software~Rotula/`, data type + *String*, value ``, ``, or for a policy with a value + `` (Language) or ``. + Use `./User/...` instead of `./Device/...` for a per-user policy. +- **Settings catalog / remediation script**: where neither is possible, a PowerShell script can write the values in the + table below directly under `HKLM\SOFTWARE\Policies\P4Software\Rotula`. + +## Group Policy + +### Software installation + +1. Copy the MSI to a share that the computer accounts can read, for example `\\fileserver\Software$\Rotula\`. +2. In the Group Policy Management Editor: **Computer Configuration > Policies > Software Settings > Software installation > + New > Package**, choose the MSI through its UNC path and pick **Assigned**. It installs at the next restart. +3. For a new version, add the new MSI to the same GPO and, on its **Upgrades** tab, mark it as upgrading the previous + package (*Package can upgrade over the existing package*). + +### Administrative templates + +Copy `packaging/policies/Rotula.admx` to `\\\SYSVOL\\Policies\PolicyDefinitions\` (the central store) and +`en-US\Rotula.adml` and `es-ES\Rotula.adml` to the language folders below it. For one computer, use +`C:\Windows\PolicyDefinitions` instead. The settings appear under **Administrative Templates > P4 Software > Rotula** in both +Computer Configuration and User Configuration. + +## Configuration Manager + +Create an application with the deployment type **Windows Installer (\*.msi file)** and point it at the MSI. Configuration +Manager reads the ProductCode for the detection method and fills +`msiexec /i "Rotula-X.Y.Z-win-x64.msi" /qn` and the uninstall command. Set the installation behavior to **Install for system**, +the logon requirement to **Whether or not a user is logged on**, and add the new version's application with a +**supersedence** relationship to the old one (without uninstalling it first: the MSI upgrades in place). + +## Policy settings + +Rotula reads the policies once at start-up from `HKLM\SOFTWARE\Policies\P4Software\Rotula` (the computer) and +`HKCU\SOFTWARE\Policies\P4Software\Rotula` (the user). When both set the same value, **the computer's value wins**. A +setting fixed by a policy is shown disabled in Rotula with the note "Managed by your organization", and the user's own +choice is kept in `settings.json` for the day the policy is removed. The MCP server (`Rotula.Mcp.exe`) reads the same key. + +| Policy (template name) | Registry value | Type | Values | Effect | +|---|---|---|---|---| +| Language of Rotula | `Language` | REG_SZ | `auto`, `en`, `es` | Sets the language and disables **View > Language**. `auto` follows the Windows display language | +| Check for updates | `UpdateCheck` | REG_DWORD | `1` on, `0` off | Switches the daily update check on or off and disables **Help > Check for updates once a day**. Not set: each user decides (off until they switch it on) | +| Default printer | `DefaultPrinter` | REG_SZ | A Windows printer name | Preselected in the Print dialog until the user picks a printer. Not locked; ignored quietly if the printer is not installed | +| Allow data source add-ons from the user's folder | `UserAddOns` | REG_DWORD | `0` blocks | Add-ons in `%AppData%\Rotula\packs` are neither loaded nor offered for approval; the installed `packs` folder still works ([data-providers.md](data-providers.md)) | +| Allow the MCP server to print | `McpPrinting` | REG_DWORD | `0` blocks | The MCP server refuses every print request, whatever its settings file says | +| Allow the MCP server to read databases | `McpDatabase` | REG_DWORD | `0` blocks | The MCP server refuses every database query | + +A value that Rotula does not understand (a language other than the three above, a number other than 0 or 1) is ignored, so +a typo never forces a wrong setting on users. The MCP policies can only take something away: setting them to `1` does not +switch printing or database access on; the MCP server's own settings file still has to allow it ([mcp.md](mcp.md)). + +Example with PowerShell (elevated), for a whole computer: + +```powershell +$key = 'HKLM:\SOFTWARE\Policies\P4Software\Rotula' +New-Item -Path $key -Force | Out-Null +Set-ItemProperty -Path $key -Name Language -Value 'es' -Type String +Set-ItemProperty -Path $key -Name UpdateCheck -Value 0 -Type DWord +Set-ItemProperty -Path $key -Name McpPrinting -Value 0 -Type DWord +``` + +Restart Rotula after a policy change. + +## Network traffic and the update check + +Rotula makes no network request of its own unless the update check is switched on, by the user (**Help > Check for updates +once a day**) or by the *Check for updates* policy. Otherwise the only traffic is what the user asks for: a label sent to a +network printer, and queries to the databases the user connects. + +When the update check is on, Rotula sends at most one request a day: an anonymous HTTPS `GET` of +`https://api.github.com/repos/P4Software/Rotula/releases/latest`, with the User-Agent `Rotula` (no version) and no cookies, +no identifiers and nothing about the user or the computer. It uses the Windows proxy settings, with the signed-in user's +credentials for a proxy that asks for them. When a newer version exists, a notice under the toolbar links to its release page +on GitHub. Rotula never downloads or installs anything itself: updating stays with you (a new MSI through your deployment +tool, or `winget upgrade P4Software.Rotula`). To allow the check through a firewall, allow `api.github.com` on port 443. +A failed check is silent and is tried again the next day. + +## winget + +`winget install P4Software.Rotula` installs the per-user package; `winget install P4Software.Rotula --scope machine` installs +the MSI for all users (winget asks for administrator rights). The package is not in the winget catalogue yet; see the README. diff --git a/docs/mcp.md b/docs/mcp.md index a894941..ffaa035 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -56,6 +56,11 @@ a folder you own. A damaged settings file never switches anything on: the server falls back to the defaults above, leaves the file untouched and logs what is wrong. Values outside their range are clamped and mentioned in the log. +An organization can switch printing and database access off for everyone with the `McpPrinting` and `McpDatabase` policies +(Group Policy or Intune, see [deployment.md](deployment.md#policy-settings)). A policy can only take them away: when it +allows them, the settings file above still decides. Installed in Program Files (the MSI or an all-users install) the +settings file cannot be created by a normal user; the server then runs with the safe defaults and says so in its log. + ## Tools Positions and sizes are in the label's unit (millimetres unless the label was created in inches or dots), measured from the diff --git a/docs/releasing.md b/docs/releasing.md index 45aec8c..520f0a3 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -7,7 +7,9 @@ gate and the hygiene check, and keeps an unsigned portable zip as a workflow art ## One-time set-up on the build machine - .NET 10 SDK, Inno Setup 6 (`winget install JRSoftware.InnoSetup --scope user`), the Azure CLI, and the Microsoft `sign` - tool (`dotnet tool install --global sign --prerelease`). + tool (`dotnet tool install --global sign --prerelease`). The WiX Toolset that builds the MSI needs no install: `dotnet build` + restores `WixToolset.Sdk` from NuGet the first time. +- For the winget submission: `winget install Microsoft.WingetCreate` (optional, see step 8). - For publishing from the script: the GitHub CLI (`winget install GitHub.cli`, then `gh auth login`). ## Cutting a release @@ -18,7 +20,8 @@ gate and the hygiene check, and keeps an unsigned portable zip as a workflow art 3. Merge to the main branch, make sure CI is green, then create and push the tag `vX.Y.Z` on that commit. 4. Run `az login` (the signing uses that session). 5. Double-click `packaging\installer\build-installer.cmd`. It publishes the app and the MCP server (`Rotula.Mcp.exe`), writes the - combined licence text, signs both programs, builds the installer, signs it, verifies all three signatures, builds the + combined licence text, signs both programs, builds the per-user installer (Inno Setup) and the machine-wide MSI + (`packaging\msi`, WiX Toolset) from the signed programs, signs both, verifies all four signatures, builds the portable zip from the signed programs, writes the winget manifests and `SHA256SUMS.txt`. Everything is in `artifacts\` (git-ignored). If you are not logged in, the files are still built but labelled `UNSIGNED` and must not be published. Set `ROTULA_NOPAUSE=1` to skip the final "press Enter" (automation only). @@ -28,14 +31,24 @@ gate and the hygiene check, and keeps an unsigned portable zip as a workflow art ``` gh release create vX.Y.Z --repo P4Software/Rotula --title "Rotula X.Y.Z" --verify-tag --notes-file ^ - artifacts\Rotula-X.Y.Z-win-x64-setup.exe artifacts\Rotula-X.Y.Z-win-x64-portable.zip ^ + artifacts\Rotula-X.Y.Z-win-x64-setup.exe artifacts\Rotula-X.Y.Z-win-x64.msi artifacts\Rotula-X.Y.Z-win-x64-portable.zip ^ artifacts\LICENSE-THIRD-PARTY.txt artifacts\SHA256SUMS.txt artifacts\Rotula-X.Y.Z-winget-manifests.zip ``` The script never replaces files of a release that already exists; it adds only the missing ones and says so. -8. **Winget**: copy `artifacts\winget\manifests\p\P4Software\Rotula\X.Y.Z\` into a fork of `microsoft/winget-pkgs`, check it - with `winget validate`, and open a pull request. The manifest already holds the real installer hash and download URL, so - submit it only after the GitHub Release is public. +8. **Winget** (only after the GitHub Release is public: the manifests hold the real download URLs and hashes). The set in + `artifacts\winget\manifests\p\P4Software\Rotula\X.Y.Z\` has four files: version, installer (the Inno exe for + `--scope user` and the MSI for `--scope machine`), en-US default locale and es-MX locale. + 1. Check it: `winget validate --manifest artifacts\winget\manifests\p\P4Software\Rotula\X.Y.Z`. + 2. Try it in Windows Sandbox or a test VM: `winget settings --enable LocalManifestFiles` (once, elevated), then + `winget install --manifest ` and `winget install --manifest --scope machine`, and uninstall both. + 3. Submit it, either with wingetcreate, which forks `microsoft/winget-pkgs` and opens the pull request for you: + `wingetcreate submit --token artifacts\winget\manifests\p\P4Software\Rotula\X.Y.Z` + or by hand: fork `microsoft/winget-pkgs`, copy the folder to `manifests/p/P4Software/Rotula/X.Y.Z/` on a new branch, and + open a pull request titled `New version: P4Software.Rotula version X.Y.Z` (the first one: `New package: ...`). + 4. Follow the pull request: the winget bots validate, install and scan the package and label the pull request; answer any + `Needs-Author-Feedback` label. A moderator merges it, and the version reaches `winget search` within a few hours. + Never edit the hashes by hand; if a release file changes, build again (and use a new version number). ## Verifying a signature @@ -44,7 +57,7 @@ Get-AuthenticodeSignature .\artifacts\Rotula-X.Y.Z-win-x64-setup.exe | Format-Li ``` `Status` must be `Valid`, the signer subject must contain `Barrdega Sistemas NA LLC`, and a timestamp certificate must be -present. Do the same for `Rotula.App.exe` and `Rotula.Mcp.exe` inside the portable zip. The build script performs these checks itself and never +present. Do the same for the MSI, and for `Rotula.App.exe` and `Rotula.Mcp.exe` inside the portable zip. The build script performs these checks itself and never trusts the exit code of the signing tool. Compare downloads with `SHA256SUMS.txt` (`Get-FileHash`). ## What is in a release @@ -52,6 +65,8 @@ trusts the exit code of the signing tool. Compare downloads with `SHA256SUMS.txt - `Rotula-X.Y.Z-win-x64-setup.exe`: per-user installer. Installs `Rotula.App.exe`, `Rotula.Mcp.exe`, `LICENSE`, `LICENSE-THIRD-PARTY.txt`, `THIRD-PARTY-NOTICES.md`, `README.md`, `docs\mcp.md` and an empty `packs` folder (with a one-line `README.txt`) beside the program. Uninstalling removes them, and the MCP settings file created next to the exe on first run. +- `Rotula-X.Y.Z-win-x64.msi`: machine-wide Windows Installer package for administrators (Program Files, every user; Intune, + Group Policy, Configuration Manager). The same files as the setup exe. See [deployment.md](deployment.md). - `Rotula-X.Y.Z-win-x64-portable.zip`: the same files, nothing installed. - `LICENSE-THIRD-PARTY.txt`: also a separate release file, because the winget manifest links to it as the agreement. - `SHA256SUMS.txt` and `Rotula-X.Y.Z-winget-manifests.zip`. diff --git a/packaging/installer/Rotula.iss b/packaging/installer/Rotula.iss index ee26746..6b3c743 100644 --- a/packaging/installer/Rotula.iss +++ b/packaging/installer/Rotula.iss @@ -38,7 +38,8 @@ DefaultGroupName=Rotula DisableProgramGroupPage=yes ; Per-user install: no UAC prompt, and winget can install it silently without elevation. PrivilegesRequired=lowest -PrivilegesRequiredOverridesAllowed=dialog +; /ALLUSERS (all users, Program Files, needs administrator rights) and /CURRENTUSER work on the command line for silent deployment (docs/deployment.md). +PrivilegesRequiredOverridesAllowed=commandline dialog ArchitecturesAllowed=x64compatible ArchitecturesInstallIn64BitMode=x64compatible ; The page the user must accept: Rotula's MIT licence plus the full terms of every non-permissive component that ships diff --git a/packaging/installer/build-installer.ps1 b/packaging/installer/build-installer.ps1 index 37ebc18..45b8bc0 100644 --- a/packaging/installer/build-installer.ps1 +++ b/packaging/installer/build-installer.ps1 @@ -2,8 +2,9 @@ Builds the signed Rotula release. Double-click build-installer.cmd (it runs this script). Steps: read the version, publish the app and the MCP server, write the combined licence text, - sign both programs, build the installer, sign the installer, verify all three signatures, build - the portable zip from the SIGNED programs, write the winget manifests and the checksums. Everything lands in the git-ignored "artifacts" folder. + sign both programs, build the per-user installer (Inno Setup) and the machine-wide MSI (WiX Toolset) from the SIGNED + programs, sign both, verify all four signatures, build the portable zip from the SIGNED programs, write the winget + manifests and the checksums. Everything lands in the git-ignored "artifacts" folder. Signing uses Azure Artifact Signing (account p4software) through your Azure login (az login). If signing is not possible the files are still built and clearly marked UNSIGNED, and they are @@ -223,6 +224,9 @@ try { (Join-Path ${env:ProgramFiles(x86)} 'Inno Setup 6\ISCC.exe'), (Join-Path $env:ProgramFiles 'Inno Setup 6\ISCC.exe')) | Where-Object { $_ -and (Test-Path -LiteralPath $_) } | Select-Object -First 1 if (-not $iscc) { throw "Inno Setup is not installed. Install it with: winget install JRSoftware.InnoSetup --scope user (then double-click build-installer.cmd again)" } + # The MSI is built with the WiX Toolset SDK, which dotnet restores from NuGet on the first build (nothing to install by hand). + $msiProject = Join-Path $repo 'packaging\msi\Rotula.Msi.wixproj' + if (-not (Test-Path -LiteralPath $msiProject)) { throw "The MSI project was not found at $msiProject." } # --- Version ------------------------------------------------------------------------------ if ($cfg.versionOverride) { $version = [string]$cfg.versionOverride } @@ -232,6 +236,8 @@ try { } $version = $version.Trim().TrimStart('v') if ($version -notmatch '^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$') { throw "The version '$version' is not major.minor.patch (optionally with a -suffix). Fix in Directory.Build.props." } + # Windows Installer only knows numeric versions: 0.4.0-rc1 becomes 0.4.0 inside the MSI (the file name keeps the full text). + $versionNumeric = $version.Split('-')[0] Write-Host "Building Rotula $version" -ForegroundColor Cyan # --- Signing availability ----------------------------------------------------------------- @@ -281,24 +287,35 @@ try { if ($canSign) { $signed = ((SignFile $exe) -and (SignFile $mcpExe)) } else { Write-Host ' Skipped (see the note above).' } if ($canSign -and -not $signed) { Write-Host ' Signing failed; the output will be UNSIGNED.' -ForegroundColor Yellow } - # --- 3. Installer ------------------------------------------------------------------------- - Write-Host '3/6 Building the installer...' + # --- 3. Installers ------------------------------------------------------------------------ + Write-Host '3/6 Building the installers...' & $iscc "/DAppVersion=$version" "/DSourceDir=$stage" "/DOutputDir=$artifacts" (Join-Path $PSScriptRoot 'Rotula.iss') if ($LASTEXITCODE -ne 0) { throw 'The installer could not be built. Read the messages above.' } $setup = Join-Path $artifacts "Rotula-$version-win-x64-setup.exe" if (-not (Test-Path -LiteralPath $setup)) { throw "The installer was not found at $setup after the build." } - # --- 4. Sign the installer ---------------------------------------------------------------- - Write-Host '4/6 Signing the installer...' + # The MSI packs the same stage folder, so it carries the SIGNED programs too. Its build output stays in publish\ (git-ignored). + $msiOut = Join-Path $repo 'publish\msi' + if (Test-Path -LiteralPath $msiOut) { Remove-Item -LiteralPath $msiOut -Recurse -Force } + & dotnet build $msiProject -c Release "-p:ProductVersion=$versionNumeric" "-p:StageDir=$stage" -o $msiOut + if ($LASTEXITCODE -ne 0) { throw 'The MSI could not be built. Read the messages above.' } + $builtMsi = Get-ChildItem -LiteralPath $msiOut -Recurse -Filter 'Rotula.msi' | Select-Object -First 1 + if (-not $builtMsi) { throw "The MSI was not found in $msiOut after the build." } + $msi = Join-Path $artifacts "Rotula-$version-win-x64.msi" + Copy-Item -LiteralPath $builtMsi.FullName -Destination $msi + + # --- 4. Sign the installers --------------------------------------------------------------- + Write-Host '4/6 Signing the installers...' $setupSigned = $false - if ($signed) { $setupSigned = SignFile $setup } else { Write-Host ' Skipped.' } + if ($signed) { $setupSigned = ((SignFile $setup) -and (SignFile $msi)) } else { Write-Host ' Skipped.' } # --- 5. Verify ---------------------------------------------------------------------------- Write-Host '5/6 Checking the signatures...' Show-Signature $exe Show-Signature $mcpExe Show-Signature $setup - $allSigned = ((Test-Signed $exe) -and (Test-Signed $mcpExe) -and (Test-Signed $setup)) + Show-Signature $msi + $allSigned = ((Test-Signed $exe) -and (Test-Signed $mcpExe) -and (Test-Signed $setup) -and (Test-Signed $msi)) # --- 6. Zip, manifests, checksums --------------------------------------------------------- Write-Host '6/6 Packaging the portable zip, winget manifests and checksums...' @@ -312,13 +329,16 @@ try { if ($allSigned) { $wingetOut = Join-Path $artifacts 'winget' - & (Join-Path $repo 'scripts\New-WingetManifest.ps1') -Version $version -InstallerPath $setup -OutputDir $wingetOut -Repository $cfg.githubRepository + & (Join-Path $repo 'scripts\New-WingetManifest.ps1') -Version $version -InstallerPath $setup -MsiPath $msi -OutputDir $wingetOut -Repository $cfg.githubRepository New-ZipFromFolder $wingetOut (Join-Path $artifacts "Rotula-$version-winget-manifests.zip") } else { # Rename so an unsigned installer can never be mistaken for the release file; no winget files for it. $unsignedSetup = Join-Path $artifacts "Rotula-$version-win-x64-setup-UNSIGNED.exe" Move-Item -LiteralPath $setup -Destination $unsignedSetup $setup = $unsignedSetup + $unsignedMsi = Join-Path $artifacts "Rotula-$version-win-x64-UNSIGNED.msi" + Move-Item -LiteralPath $msi -Destination $unsignedMsi + $msi = $unsignedMsi Set-Content -LiteralPath (Join-Path $artifacts 'UNSIGNED-DO-NOT-PUBLISH.txt') -Encoding ASCII -Value @( 'These files are NOT signed. Do not publish them.', "Run 'az login', then double-click build-installer.cmd again to get signed files.") @@ -339,7 +359,7 @@ try { if ($cfg.publishToGitHub) { Write-Host 'publishToGitHub is true, but nothing was published because the files are not signed.' -ForegroundColor Yellow } } else { Write-Host '' - Write-Host "Done. All programs and the installer are signed by '$expectedSigner'. Output: $artifacts" -ForegroundColor Green + Write-Host "Done. All programs and both installers are signed by '$expectedSigner'. Output: $artifacts" -ForegroundColor Green # --- Optional publish ------------------------------------------------------------------- if (-not $cfg.publishToGitHub) { @@ -352,7 +372,7 @@ try { $null = & gh api "repos/$repoName/git/ref/tags/$tag" 2>$null if ($LASTEXITCODE -ne 0) { throw "The tag $tag does not exist on GitHub yet. Tag the commit you built and push the tag, then build again." } - $assets = @($setup, $zip, $licenseAsset, (Join-Path $artifacts 'SHA256SUMS.txt'), (Join-Path $artifacts "Rotula-$version-winget-manifests.zip")) + $assets = @($setup, $msi, $zip, $licenseAsset, (Join-Path $artifacts 'SHA256SUMS.txt'), (Join-Path $artifacts "Rotula-$version-winget-manifests.zip")) $notes = Join-Path $artifacts 'release-notes.md' $haveNotes = Get-ReleaseNotes $version $notes $null = & gh release view $tag --repo $repoName 2>$null diff --git a/packaging/msi/Package.wxs b/packaging/msi/Package.wxs new file mode 100644 index 0000000..74d12d2 --- /dev/null +++ b/packaging/msi/Package.wxs @@ -0,0 +1,98 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packaging/msi/Rotula.Msi.wixproj b/packaging/msi/Rotula.Msi.wixproj new file mode 100644 index 0000000..4005006 --- /dev/null +++ b/packaging/msi/Rotula.Msi.wixproj @@ -0,0 +1,29 @@ + + + + + Rotula + Package + x64 + + 0.0.0 + $(MSBuildThisFileDirectory)..\..\artifacts\stage + $(MSBuildThisFileDirectory)..\..\ + ProductVersion=$(ProductVersion);StageDir=$(StageDir);RepoRoot=$(RepoRoot) + + false + + + + + + + diff --git a/packaging/policies/Rotula.admx b/packaging/policies/Rotula.admx new file mode 100644 index 0000000..f0f341e --- /dev/null +++ b/packaging/policies/Rotula.admx @@ -0,0 +1,86 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + auto + + + en + + + es + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packaging/policies/en-US/Rotula.adml b/packaging/policies/en-US/Rotula.adml new file mode 100644 index 0000000..2283b0e --- /dev/null +++ b/packaging/policies/en-US/Rotula.adml @@ -0,0 +1,78 @@ + + + Rotula + Settings for the Rotula label designer. + + + Rotula 0.4.0 or later + P4 Software + Rotula + + Language of Rotula + Sets the language of Rotula and locks it: View > Language is disabled and says "Managed by your organization". + +"Same as Windows" uses Spanish when the Windows display language is Spanish and English otherwise. + +Not configured or disabled: each user chooses the language. + +Registry value: Language (text: auto, en or es). + Same as Windows + English + Spanish + + Check for updates + Controls the daily update check and locks the choice (Help > Check for updates once a day). + +Enabled: once a day Rotula asks the public GitHub page of the project which version is the newest, with one anonymous request. Nothing about the user or the computer is sent. When a newer version exists, a notice with a link to the release page appears. Rotula never downloads or installs anything itself. + +Disabled: Rotula makes no update request at all, and users cannot switch it on. + +Not configured: each user decides. The check is off until the user switches it on. + +Registry value: UpdateCheck (number: 1 on, 0 off). + + Default printer + Preselects this Windows printer in the Print dialog for users who have not chosen a printer yet. Users can still choose another printer; their choice is remembered. + +Type the printer name exactly as Windows shows it. If the printer is not installed on the computer, Rotula falls back to the Windows default printer without a message. + +Registry value: DefaultPrinter (text). + + Allow data source add-ons from the user's folder + Data source add-ons are programs. Rotula loads add-ons from the "packs" folder next to the program (installed by an administrator) and, after the user approves each one, from %AppData%\Rotula\packs. + +Disabled: add-ons in the user's folder are not loaded and not offered for approval. Add-ons in the installed "packs" folder still work. + +Enabled or not configured: users may approve add-ons in their own folder. + +Registry value: UserAddOns (number: 0 blocks the user's folder). + + Allow the MCP server to print + The Rotula MCP server (Rotula.Mcp.exe) lets an AI assistant design labels. Printing is off unless its settings file allows it. + +Disabled: the MCP server never prints, whatever its settings file says. + +Enabled or not configured: the settings file of the MCP server decides (allowPrinting and allowedPrinters). This policy cannot switch printing on by itself. + +Registry value: McpPrinting (number: 0 blocks). + + Allow the MCP server to read databases + Disabled: the Rotula MCP server never reads saved database connections, whatever its settings file says. + +Enabled or not configured: the settings file of the MCP server decides (allowDatabase). This policy cannot switch database access on by itself. + +Registry value: McpDatabase (number: 0 blocks). + + + + Language: + + + + + + + + + diff --git a/packaging/policies/es-ES/Rotula.adml b/packaging/policies/es-ES/Rotula.adml new file mode 100644 index 0000000..e6fa9d4 --- /dev/null +++ b/packaging/policies/es-ES/Rotula.adml @@ -0,0 +1,78 @@ + + + Rotula + Configuración del diseñador de etiquetas Rotula. + + + Rotula 0.4.0 o posterior + P4 Software + Rotula + + Idioma de Rotula + Fija el idioma de Rotula y lo bloquea: Ver > Idioma queda desactivado e indica "Administrado por tu organización". + +"Igual que Windows" usa el español cuando el idioma de presentación de Windows es español, y el inglés en los demás casos. + +Sin configurar o deshabilitada: cada usuario elige el idioma. + +Valor del registro: Language (texto: auto, en o es). + Igual que Windows + Inglés + Español + + Buscar actualizaciones + Controla la búsqueda diaria de actualizaciones y bloquea la opción (Ayuda > Buscar actualizaciones una vez al día). + +Habilitada: una vez al día Rotula pregunta a la página pública del proyecto en GitHub cuál es la versión más reciente, con una sola solicitud anónima. No se envía nada sobre el usuario ni sobre el equipo. Si hay una versión más reciente, aparece un aviso con un enlace a la página de la versión. Rotula nunca descarga ni instala nada por sí mismo. + +Deshabilitada: Rotula no hace ninguna solicitud de actualización y los usuarios no pueden activarla. + +Sin configurar: decide cada usuario. La búsqueda está desactivada hasta que el usuario la activa. + +Valor del registro: UpdateCheck (número: 1 activada, 0 desactivada). + + Impresora predeterminada + Preselecciona esta impresora de Windows en el cuadro Imprimir para los usuarios que todavía no han elegido una impresora. Los usuarios pueden elegir otra; su elección se recuerda. + +Escribe el nombre de la impresora exactamente como lo muestra Windows. Si la impresora no está instalada en el equipo, Rotula usa la impresora predeterminada de Windows sin mostrar ningún mensaje. + +Valor del registro: DefaultPrinter (texto). + + Permitir complementos de orígenes de datos en la carpeta del usuario + Los complementos de orígenes de datos son programas. Rotula carga los complementos de la carpeta "packs" junto al programa (instalados por un administrador) y, después de que el usuario apruebe cada uno, los de %AppData%\Rotula\packs. + +Deshabilitada: los complementos de la carpeta del usuario no se cargan ni se ofrecen para aprobar. Los de la carpeta "packs" instalada siguen funcionando. + +Habilitada o sin configurar: los usuarios pueden aprobar complementos en su propia carpeta. + +Valor del registro: UserAddOns (número: 0 bloquea la carpeta del usuario). + + Permitir que el servidor MCP imprima + El servidor MCP de Rotula (Rotula.Mcp.exe) permite que un asistente de IA diseñe etiquetas. La impresión está desactivada salvo que su archivo de configuración la permita. + +Deshabilitada: el servidor MCP nunca imprime, diga lo que diga su archivo de configuración. + +Habilitada o sin configurar: decide el archivo de configuración del servidor MCP (allowPrinting y allowedPrinters). Esta directiva no puede activar la impresión por sí sola. + +Valor del registro: McpPrinting (número: 0 bloquea). + + Permitir que el servidor MCP lea bases de datos + Deshabilitada: el servidor MCP de Rotula nunca lee las conexiones a bases de datos guardadas, diga lo que diga su archivo de configuración. + +Habilitada o sin configurar: decide el archivo de configuración del servidor MCP (allowDatabase). Esta directiva no puede activar el acceso a bases de datos por sí sola. + +Valor del registro: McpDatabase (número: 0 bloquea). + + + + Idioma: + + + + + + + + + diff --git a/packaging/winget/P4Software.Rotula.installer.yaml b/packaging/winget/P4Software.Rotula.installer.yaml index 98e2266..e936b7e 100644 --- a/packaging/winget/P4Software.Rotula.installer.yaml +++ b/packaging/winget/P4Software.Rotula.installer.yaml @@ -1,22 +1,42 @@ -# yaml-language-server: $schema=https://aka.ms/winget-manifest.installer.1.9.0.schema.json +# yaml-language-server: $schema=https://aka.ms/winget-manifest.installer.1.10.0.schema.json +# Two installers for the same package: the per-user Inno Setup installer (no administrator rights; the default for +# "winget install P4Software.Rotula") and the machine-wide MSI ("--scope machine"). Double-underscore placeholders are filled by +# scripts/New-WingetManifest.ps1, which reads the MSI's ProductCode from the signed package. PackageIdentifier: P4Software.Rotula PackageVersion: __VERSION__ -InstallerType: inno -Scope: user InstallModes: - interactive - silent - silentWithProgress UpgradeBehavior: install -# Inno Setup registers the product as _is1; naming it lets winget match the installed app for upgrades. -AppsAndFeaturesEntries: - - DisplayName: Rotula - Publisher: P4 Software - ProductCode: "{6B0E5C1A-3F7D-4C52-9A1E-2D8F4B7A9C31}_is1" +FileExtensions: + - rotula +ReleaseDate: __RELEASE_DATE__ Installers: - Architecture: x64 + InstallerType: inno + Scope: user InstallerUrl: __INSTALLER_URL__ InstallerSha256: __INSTALLER_SHA256__ -ReleaseDate: __RELEASE_DATE__ + # Inno Setup registers the product as _is1; naming it lets winget match the installed app for upgrades. + ProductCode: "{6B0E5C1A-3F7D-4C52-9A1E-2D8F4B7A9C31}_is1" + AppsAndFeaturesEntries: + - DisplayName: Rotula + Publisher: P4 Software + ProductCode: "{6B0E5C1A-3F7D-4C52-9A1E-2D8F4B7A9C31}_is1" + - Architecture: x64 + InstallerType: wix + Scope: machine + InstallerUrl: __MSI_URL__ + InstallerSha256: __MSI_SHA256__ + # A per-machine MSI needs administrator rights; winget asks for them before it runs msiexec silently. + ElevationRequirement: elevationRequired + # The ProductCode changes with every version (MajorUpgrade); the UpgradeCode never does. + ProductCode: "__MSI_PRODUCT_CODE__" + AppsAndFeaturesEntries: + - DisplayName: Rotula + Publisher: P4 Software + ProductCode: "__MSI_PRODUCT_CODE__" + UpgradeCode: "{4B2C6076-5B82-411C-B751-EC4B70BEE9C6}" ManifestType: installer -ManifestVersion: 1.9.0 +ManifestVersion: 1.10.0 diff --git a/packaging/winget/P4Software.Rotula.locale.en-US.yaml b/packaging/winget/P4Software.Rotula.locale.en-US.yaml index 43d4a9e..00cfd39 100644 --- a/packaging/winget/P4Software.Rotula.locale.en-US.yaml +++ b/packaging/winget/P4Software.Rotula.locale.en-US.yaml @@ -1,4 +1,4 @@ -# yaml-language-server: $schema=https://aka.ms/winget-manifest.defaultLocale.1.9.0.schema.json +# yaml-language-server: $schema=https://aka.ms/winget-manifest.defaultLocale.1.10.0.schema.json PackageIdentifier: P4Software.Rotula PackageVersion: __VERSION__ PackageLocale: en-US @@ -15,7 +15,8 @@ Description: >- Rotula is a free, open source, offline-first label designer. Lay out text, barcodes, QR codes, boxes and images, merge data from CSV or Excel files, preview the label and print ZPL-compatible output to USB, spooler or network (port 9100) printers. It also includes an MCP server for AI assistants and read-only - database sources. It sends no telemetry and connects only to the printers and databases you set up. + database sources. It sends no telemetry and connects only to the printers and databases you set up, plus, only + if you switch it on, one anonymous daily check for a newer version. Moniker: rotula Tags: - label @@ -32,7 +33,9 @@ Agreements: Documentations: - DocumentLabel: MCP server (connect an AI assistant) DocumentUrl: https://github.com/P4Software/Rotula/blob/main/docs/mcp.md + - DocumentLabel: Deployment (silent install, MSI, policies) + DocumentUrl: https://github.com/P4Software/Rotula/blob/main/docs/deployment.md - DocumentLabel: Third-party notices DocumentUrl: https://github.com/P4Software/Rotula/blob/main/THIRD-PARTY-NOTICES.md ManifestType: defaultLocale -ManifestVersion: 1.9.0 +ManifestVersion: 1.10.0 diff --git a/packaging/winget/P4Software.Rotula.locale.es-MX.yaml b/packaging/winget/P4Software.Rotula.locale.es-MX.yaml new file mode 100644 index 0000000..7dae67f --- /dev/null +++ b/packaging/winget/P4Software.Rotula.locale.es-MX.yaml @@ -0,0 +1,35 @@ +# yaml-language-server: $schema=https://aka.ms/winget-manifest.locale.1.10.0.schema.json +# Spanish texts for "winget show P4Software.Rotula" on Spanish systems. Fields not listed here come from the en-US manifest. +PackageIdentifier: P4Software.Rotula +PackageVersion: __VERSION__ +PackageLocale: es-MX +Publisher: P4 Software +PublisherUrl: https://github.com/P4Software +PublisherSupportUrl: https://github.com/P4Software/Rotula/issues +PackageName: Rotula +PackageUrl: https://github.com/P4Software/Rotula +License: MIT +LicenseUrl: https://github.com/P4Software/Rotula/blob/main/LICENSE +Copyright: Copyright (c) P4 Software +ShortDescription: Diseñador de etiquetas para Windows, sin conexión, con salida compatible con ZPL. +Description: >- + Rotula es un diseñador de etiquetas libre, de código abierto y que funciona sin conexión. Coloca textos, códigos de + barras, códigos QR, recuadros e imágenes, combina datos de archivos CSV o Excel, revisa la vista previa e imprime + salida compatible con ZPL en impresoras USB, de cola de Windows o de red (puerto 9100). Incluye además un servidor MCP + para asistentes de IA y orígenes de datos de solo lectura. No envía telemetría y solo se conecta a las impresoras y bases + de datos que configures y, únicamente si lo activas, a una comprobación anónima diaria de nuevas versiones. +Tags: + - etiquetas + - diseñador-de-etiquetas + - codigo-de-barras + - zpl + - impresion +ReleaseNotesUrl: __RELEASE_NOTES_URL__ +Agreements: + - AgreementLabel: Licencia de Rotula y condiciones de terceros (en inglés) + AgreementUrl: __LICENSE_THIRD_PARTY_URL__ +Documentations: + - DocumentLabel: Instalación en empresas (instalación silenciosa, MSI, directivas) + DocumentUrl: https://github.com/P4Software/Rotula/blob/main/docs/deployment.md +ManifestType: locale +ManifestVersion: 1.10.0 diff --git a/packaging/winget/P4Software.Rotula.yaml b/packaging/winget/P4Software.Rotula.yaml index d7a5ace..7624483 100644 --- a/packaging/winget/P4Software.Rotula.yaml +++ b/packaging/winget/P4Software.Rotula.yaml @@ -1,7 +1,7 @@ -# yaml-language-server: $schema=https://aka.ms/winget-manifest.version.1.9.0.schema.json -# Double-underscore placeholders are filled by scripts/New-WingetManifest.ps1 (run by the release workflow). +# yaml-language-server: $schema=https://aka.ms/winget-manifest.version.1.10.0.schema.json +# Double-underscore placeholders are filled by scripts/New-WingetManifest.ps1 (run by packaging/installer/build-installer.ps1). PackageIdentifier: P4Software.Rotula PackageVersion: __VERSION__ DefaultLocale: en-US ManifestType: version -ManifestVersion: 1.9.0 +ManifestVersion: 1.10.0 diff --git a/scripts/New-WingetManifest.ps1 b/scripts/New-WingetManifest.ps1 index fc05fb2..d141528 100644 --- a/scripts/New-WingetManifest.ps1 +++ b/scripts/New-WingetManifest.ps1 @@ -2,36 +2,60 @@ .SYNOPSIS Fills the winget manifest templates in packaging/winget for one release. .DESCRIPTION - Replaces the __PLACEHOLDER__ tokens (version, installer URL, SHA-256, release date, notes URL, third-party licence URL) - and writes the ready-to-submit manifest set to -OutputDir/manifests/p/P4Software/Rotula//, - the folder layout the winget-pkgs repository expects. + Replaces the __PLACEHOLDER__ tokens (version, installer URLs, SHA-256 hashes, the MSI's ProductCode, release date, notes URL, + third-party licence URL) and writes the ready-to-submit manifest set to -OutputDir/manifests/p/P4Software/Rotula//, + the folder layout the winget-pkgs repository expects. The ProductCode is read from the MSI itself (Windows only). .EXAMPLE - ./scripts/New-WingetManifest.ps1 -Version 0.1.0 -InstallerPath artifacts/Rotula-0.1.0-win-x64-setup.exe -OutputDir artifacts/winget + ./scripts/New-WingetManifest.ps1 -Version 0.4.0 -InstallerPath artifacts/Rotula-0.4.0-win-x64-setup.exe -MsiPath artifacts/Rotula-0.4.0-win-x64.msi -OutputDir artifacts/winget #> param( [Parameter(Mandatory)] [string] $Version, [Parameter(Mandatory)] [string] $InstallerPath, + [Parameter(Mandatory)] [string] $MsiPath, [Parameter(Mandatory)] [string] $OutputDir, [string] $Repository = 'P4Software/Rotula' ) $ErrorActionPreference = 'Stop' + +function Get-MsiProperty([string] $path, [string] $name) { + # Reads one row of the MSI's Property table through the Windows Installer automation interface (works in PowerShell 5.1 and 7). + $installer = New-Object -ComObject WindowsInstaller.Installer + try { + $db = $installer.GetType().InvokeMember('OpenDatabase', 'InvokeMethod', $null, $installer, @((Resolve-Path -LiteralPath $path).Path, 0)) + $view = $db.GetType().InvokeMember('OpenView', 'InvokeMethod', $null, $db, @("SELECT ``Value`` FROM ``Property`` WHERE ``Property`` = '$name'")) + [void]$view.GetType().InvokeMember('Execute', 'InvokeMethod', $null, $view, $null) + $record = $view.GetType().InvokeMember('Fetch', 'InvokeMethod', $null, $view, $null) + if (-not $record) { throw "The MSI $path has no $name." } + $value = $record.GetType().InvokeMember('StringData', 'GetProperty', $null, $record, 1) + [void]$view.GetType().InvokeMember('Close', 'InvokeMethod', $null, $view, $null) + return $value + } finally { + [void][System.Runtime.InteropServices.Marshal]::ReleaseComObject($installer) + } +} + $Version = $Version.TrimStart('v') $tag = "v$Version" -$name = Split-Path -Leaf $InstallerPath -$sha = (Get-FileHash -Algorithm SHA256 -LiteralPath $InstallerPath).Hash.ToUpperInvariant() +$base = "https://github.com/$Repository/releases/download/$tag" +$productCode = Get-MsiProperty $MsiPath 'ProductCode' +if ($productCode -notmatch '^\{[0-9A-Fa-f-]{36}\}$') { throw "The MSI ProductCode '$productCode' does not look like a GUID." } $tokens = @{ '__VERSION__' = $Version - '__INSTALLER_URL__' = "https://github.com/$Repository/releases/download/$tag/$name" - '__INSTALLER_SHA256__' = $sha + '__INSTALLER_URL__' = "$base/$(Split-Path -Leaf $InstallerPath)" + '__INSTALLER_SHA256__' = (Get-FileHash -Algorithm SHA256 -LiteralPath $InstallerPath).Hash.ToUpperInvariant() + '__MSI_URL__' = "$base/$(Split-Path -Leaf $MsiPath)" + '__MSI_SHA256__' = (Get-FileHash -Algorithm SHA256 -LiteralPath $MsiPath).Hash.ToUpperInvariant() + '__MSI_PRODUCT_CODE__' = $productCode.ToUpperInvariant() '__RELEASE_DATE__' = (Get-Date).ToUniversalTime().ToString('yyyy-MM-dd') '__RELEASE_NOTES_URL__' = "https://github.com/$Repository/releases/tag/$tag" - '__LICENSE_THIRD_PARTY_URL__' = "https://github.com/$Repository/releases/download/$tag/LICENSE-THIRD-PARTY.txt" + '__LICENSE_THIRD_PARTY_URL__' = "$base/LICENSE-THIRD-PARTY.txt" } $dest = Join-Path $OutputDir "manifests/p/P4Software/Rotula/$Version" New-Item -ItemType Directory -Force -Path $dest | Out-Null $templates = Join-Path $PSScriptRoot '../packaging/winget' foreach ($file in Get-ChildItem $templates -Filter 'P4Software.Rotula*.yaml') { - $text = Get-Content -LiteralPath $file.FullName -Raw + # -Encoding UTF8: the Spanish locale file has accents, and Windows PowerShell 5.1 would read a BOM-less file as ANSI. + $text = Get-Content -LiteralPath $file.FullName -Raw -Encoding UTF8 foreach ($key in $tokens.Keys) { $text = $text.Replace($key, $tokens[$key]) } if ($text -match '__[A-Z_]+__') { throw "Unfilled placeholder in $($file.Name): $($Matches[0])" } # winget-pkgs expects UTF-8 without a BOM; write through .NET so Windows PowerShell 5 and pwsh 7 behave the same. diff --git a/src/Rotula.App/App.xaml.cs b/src/Rotula.App/App.xaml.cs index 1b61666..3fe8304 100644 --- a/src/Rotula.App/App.xaml.cs +++ b/src/Rotula.App/App.xaml.cs @@ -36,8 +36,11 @@ protected override void OnStartup(StartupEventArgs e) try { + // What the organization fixed through Group Policy or Intune (HKLM wins over HKCU; docs/deployment.md). Read once. + var policy = Rotula.Files.Policy.RotulaPolicy.Read(Rotula.Files.Policy.RegistryPolicySource.ForThisComputer()); // Add-ons load on a background thread while the window opens; a failure of the loader itself only ends up in the log. - var packOptions = new Rotula.Data.Packs.PackLoadOptions(); + // A policy can keep add-ons in the user's own folder from loading (the installed "packs" folder is still used). + var packOptions = new Rotula.Data.Packs.PackLoadOptions { IncludeUserPacks = policy.AllowUserAddOns }; var packLoading = PackStartup.Begin(packOptions, LogCrash); // The first New label dialog should find the stock library already read. StockLibrary.Preload(); @@ -47,13 +50,18 @@ protected override void OnStartup(StartupEventArgs e) new WpfNewLabelDialogService(), CreateZplImporter(), new Rotula.Printing.PrintService(), new JsonSettingsStore(), dialogs, new DatabaseServices(), // Serial numbers that remember their value live in %AppData%\Rotula\counters.json (see docs/variables.md). // Windows-font text is drawn to a picture by the SkiaSharp rasterizer, so Print, Save ZPL, Check All and Export can all print it. - new VariableServices(Rotula.Files.FileCounterStore.OpenDefault(), dialogs, textRasterizer: new Rotula.Rendering.SkiaTextRasterizer())); + new VariableServices(Rotula.Files.FileCounterStore.OpenDefault(), dialogs, textRasterizer: new Rotula.Rendering.SkiaTextRasterizer()), + Policy: policy, + // The opt-in update check: nothing is sent unless the user (Help menu) or a policy switches it on. + Updates: new UpdateChecker(new GitHubReleaseFeed(), Views.AboutWindow.VersionString()), + Links: new ShellUrlLauncher()); var vm = new MainViewModel(services); var window = new MainWindow(vm); MainWindow = window; window.Show(); // When loading is over, tell the user (non-modally) about add-ons that could not be loaded or wait for approval. _ = ShowPackNoticeAsync(packLoading, packOptions, vm); + _ = CheckForUpdatesAsync(vm); // Double-clicking a .rotula file starts the program with the path as its only argument. if (e.Args.Length > 0 && File.Exists(e.Args[0])) vm.LoadFile(e.Args[0]); @@ -120,6 +128,19 @@ private async Task ShowPackNoticeAsync(Task lo } } + private static async Task CheckForUpdatesAsync(MainViewModel vm) + { + try + { + await vm.CheckForUpdatesAsync(); + } + catch (Exception ex) + { + // The update check is a convenience; it must never disturb the user. + LogCrash(ex); + } + } + /// The ZPL importer: the parser that ships in Rotula.Core. private static IZplImporter CreateZplImporter() => new CoreZplImporter(); diff --git a/src/Rotula.App/MainWindow.xaml b/src/Rotula.App/MainWindow.xaml index b8cdb86..5ad68ed 100644 --- a/src/Rotula.App/MainWindow.xaml +++ b/src/Rotula.App/MainWindow.xaml @@ -215,6 +215,9 @@ + + + @@ -252,6 +255,11 @@ + + + + @@ -346,8 +354,25 @@ - - + + + + + + + + + + @@ -360,6 +385,7 @@ + diff --git a/src/Rotula.App/Resources/Strings.en.resx b/src/Rotula.App/Resources/Strings.en.resx index ce840b1..e0c3c33 100644 --- a/src/Rotula.App/Resources/Strings.en.resx +++ b/src/Rotula.App/Resources/Strings.en.resx @@ -6233,4 +6233,29 @@ Brand and product names belong to their owners; Rotula is not affiliated with th "exitPinHash" is not a PIN code made by Rotula. Create it with File > Create print station PIN (a PIN is never stored as plain text). + + + Managed by your organization + + + Check for updates once a day + + + When on, Rotula asks the project page on GitHub once a day whether a newer version exists. Nothing about you or this computer is sent, and nothing is downloaded or installed. + + + Rotula {0} is available. You can download it from the release page. + + + Update available + + + View release + + + Close this notice + + + The browser could not be opened. The release page is {0} + diff --git a/src/Rotula.App/Resources/Strings.es.resx b/src/Rotula.App/Resources/Strings.es.resx index 3096487..1250821 100644 --- a/src/Rotula.App/Resources/Strings.es.resx +++ b/src/Rotula.App/Resources/Strings.es.resx @@ -6233,4 +6233,29 @@ Las marcas y los nombres de producto pertenecen a sus dueños; Rotula no está a "exitPinHash" no es un código de PIN creado por Rotula. Créelo con Archivo > Crear PIN del puesto de impresión (el PIN nunca se guarda como texto). + + + Administrado por tu organización + + + Buscar actualizaciones una vez al día + + + Si está activado, Rotula pregunta una vez al día a la página del proyecto en GitHub si hay una versión más reciente. No se envía nada sobre ti ni sobre este equipo, y no se descarga ni se instala nada. + + + Rotula {0} está disponible. Puedes descargarlo desde la página de la versión. + + + Actualización disponible + + + Ver la versión + + + Cerrar este aviso + + + No se pudo abrir el navegador. La página de la versión es {0} + diff --git a/src/Rotula.App/Services/AppSettings.cs b/src/Rotula.App/Services/AppSettings.cs index a9da076..4a7a80a 100644 --- a/src/Rotula.App/Services/AppSettings.cs +++ b/src/Rotula.App/Services/AppSettings.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using Rotula.Files.Policy; namespace Rotula.App.Services; @@ -46,6 +47,24 @@ public sealed class AppSettings /// The printer and printer profile used last time, so the Print dialog opens ready to go. public PrintSettings Print { get; set; } = new(); + + /// + /// The user switched on the daily update check (Help menu). Off by default: without it Rotula makes no network request + /// of its own. An organization policy overrides it (see ). + /// + public bool UpdateCheckEnabled { get; set; } + + /// When the release page was last asked (UTC); null before the first check. + public DateTimeOffset? LastUpdateCheckUtc { get; set; } + + /// The newer version whose notice the user closed, so that notice is not shown again for the same version. + public string? UpdateDismissedVersion { get; set; } + + /// The language in use: the organization's choice when a policy sets one, otherwise the user's own. + public string LanguageIn(RotulaPolicy policy) => policy.Language ?? Language; + + /// Whether the update check runs: a policy that switches it on or off wins over the user's choice. + public bool UpdateCheckIn(RotulaPolicy policy) => policy.UpdateCheck ?? UpdateCheckEnabled; } /// The last printer and printer profile, stored in settings.json. Enum values are stored by name. @@ -113,7 +132,8 @@ public sealed class JsonSettingsStore : ISettingsStore private const string Header = "// Rotula settings. Edit with the program closed, or change them from the menus.\n" + "// language: \"auto\" follows Windows, or use \"en\" / \"es\".\n" + - "// unit: \"Millimeters\", \"Inches\" or \"Dots\".\n"; + "// unit: \"Millimeters\", \"Inches\" or \"Dots\".\n" + + "// updateCheckEnabled: true asks GitHub once a day whether a newer version exists (off by default; nothing is sent about you).\n"; private static readonly JsonSerializerOptions Options = new() { diff --git a/src/Rotula.App/Services/ServiceInterfaces.cs b/src/Rotula.App/Services/ServiceInterfaces.cs index 5aad106..b0738c5 100644 --- a/src/Rotula.App/Services/ServiceInterfaces.cs +++ b/src/Rotula.App/Services/ServiceInterfaces.cs @@ -171,3 +171,10 @@ void ShowPrintStation(Rotula.App.ViewModels.StationViewModel viewModel) { } /// Shows the tool that makes the exit PIN line for station.json. The default shows nothing. void ShowStationPinTool(Rotula.App.ViewModels.StationPinViewModel viewModel) { } } + +/// Opens a web address in the user's browser (the update notice's "View release" link). +public interface IUrlLauncher +{ + /// Opens ; returns false when no browser could be started. + bool Open(string url); +} diff --git a/src/Rotula.App/Services/UpdateCheck.cs b/src/Rotula.App/Services/UpdateCheck.cs new file mode 100644 index 0000000..d1f0771 --- /dev/null +++ b/src/Rotula.App/Services/UpdateCheck.cs @@ -0,0 +1,291 @@ +using System.Globalization; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text.Json; + +namespace Rotula.App.Services; + +// The opt-in update check. Rules (docs/decisions.md, "Update check"): off unless the user or an administrator switches it on; +// at most one anonymous GET of the public "latest release" page of the GitHub API a day; nothing about the user or the computer +// is sent; nothing is ever downloaded or installed. Everything except the HTTP call itself is plain code with tests. + +/// A semantic version (major.minor.patch with an optional pre-release part), compared by the SemVer 2.0 rules. +public sealed class SemanticVersion : IComparable, IEquatable +{ + private readonly string[] _pre; + + private SemanticVersion(int major, int minor, int patch, string[] pre) + { + Major = major; + Minor = minor; + Patch = patch; + _pre = pre; + } + + /// Major version. + public int Major { get; } + + /// Minor version. + public int Minor { get; } + + /// Patch version. + public int Patch { get; } + + /// The pre-release part after "-" (for example "rc1"), or empty for a normal release. + public string PreRelease => string.Join('.', _pre); + + /// True for a pre-release such as 0.4.0-rc1. + public bool IsPreRelease => _pre.Length > 0; + + /// + /// Reads "1.2.3", "v1.2.3", "1.2.3-rc.1" or "1.2.3+build" (build metadata is ignored, as SemVer says). Returns false for + /// anything else, so a strange tag on the release page can never be taken for a newer version. + /// + public static bool TryParse(string? text, out SemanticVersion version) + { + version = null!; + if (string.IsNullOrWhiteSpace(text)) return false; + string s = text.Trim(); + if (s.Length > 64) return false; + if (s[0] is 'v' or 'V') s = s[1..]; + int plus = s.IndexOf('+'); + if (plus >= 0) s = s[..plus]; + string[] pre = Array.Empty(); + int dash = s.IndexOf('-'); + if (dash >= 0) + { + pre = s[(dash + 1)..].Split('.'); + s = s[..dash]; + if (pre.Any(p => p.Length == 0 || !p.All(c => char.IsAsciiLetterOrDigit(c) || c == '-'))) return false; + } + var parts = s.Split('.'); + if (parts.Length != 3) return false; + var numbers = new int[3]; + for (int i = 0; i < 3; i++) + { + if (parts[i].Length == 0 || !parts[i].All(char.IsAsciiDigit)) return false; + if (!int.TryParse(parts[i], NumberStyles.None, CultureInfo.InvariantCulture, out numbers[i])) return false; + } + version = new SemanticVersion(numbers[0], numbers[1], numbers[2], pre); + return true; + } + + /// + public int CompareTo(SemanticVersion? other) + { + if (other is null) return 1; + int c = Major.CompareTo(other.Major); + if (c == 0) c = Minor.CompareTo(other.Minor); + if (c == 0) c = Patch.CompareTo(other.Patch); + if (c != 0) return c; + // A normal release is newer than any pre-release of the same number. + if (_pre.Length == 0 && other._pre.Length == 0) return 0; + if (_pre.Length == 0) return 1; + if (other._pre.Length == 0) return -1; + for (int i = 0; i < Math.Min(_pre.Length, other._pre.Length); i++) + { + c = ComparePart(_pre[i], other._pre[i]); + if (c != 0) return c; + } + return _pre.Length.CompareTo(other._pre.Length); + } + + private static int ComparePart(string a, string b) + { + bool an = a.All(char.IsAsciiDigit), bn = b.All(char.IsAsciiDigit); + if (an && bn) + { + // Compare digit strings without parsing, so a very long number cannot overflow. + string ta = a.TrimStart('0'), tb = b.TrimStart('0'); + return ta.Length != tb.Length ? ta.Length.CompareTo(tb.Length) : string.CompareOrdinal(ta, tb); + } + if (an) return -1; // numeric identifiers sort before alphanumeric ones + if (bn) return 1; + return string.CompareOrdinal(a, b); + } + + /// + public bool Equals(SemanticVersion? other) => CompareTo(other) == 0; + + /// + public override bool Equals(object? obj) => obj is SemanticVersion v && Equals(v); + + /// + public override int GetHashCode() => HashCode.Combine(Major, Minor, Patch, PreRelease); + + /// + public override string ToString() => IsPreRelease ? $"{Major}.{Minor}.{Patch}-{PreRelease}" : $"{Major}.{Minor}.{Patch}"; +} + +/// When the next update check is due. +public static class UpdateSchedule +{ + /// At most one check per this interval. + public static readonly TimeSpan Interval = TimeSpan.FromDays(1); + + /// + /// True when no check was made yet, or the last one is at least ago. A last check in the future + /// (the clock was set back) also counts as due, otherwise a wrong date could stop the check for a long time. + /// + public static bool IsDue(DateTimeOffset? lastCheckUtc, DateTimeOffset nowUtc) + { + if (lastCheckUtc is not { } last) return true; + if (last > nowUtc + TimeSpan.FromMinutes(5)) return true; + return nowUtc - last >= Interval; + } +} + +/// The newest published release. +/// Its version, read from the tag. +/// The release page on GitHub (built by Rotula, not taken from the answer). +public sealed record ReleaseInfo(SemanticVersion Version, string PageUrl); + +/// Reads the answer of the GitHub "latest release" API. +public static class GitHubRelease +{ + /// The repository whose releases are checked. + public const string Repository = "P4Software/Rotula"; + + /// The only address the update check ever asks. + public const string LatestApiUrl = "https://api.github.com/repos/" + Repository + "/releases/latest"; + + /// The list of releases, used when a release page cannot be named. + public const string ReleasesPageUrl = "https://github.com/" + Repository + "/releases"; + + /// + /// Reads tag_name of the answer. Drafts and pre-releases are ignored (the API should never return them, but a wrong + /// answer must not offer a test build). The page link is built from the parsed version, so the answer can never send the + /// user to another site. Returns null when the answer is not understood. + /// + public static ReleaseInfo? Parse(string json) + { + try + { + using var doc = JsonDocument.Parse(json, new JsonDocumentOptions { MaxDepth = 16 }); + var root = doc.RootElement; + if (root.ValueKind != JsonValueKind.Object) return null; + if (IsTrue(root, "draft") || IsTrue(root, "prerelease")) return null; + if (!root.TryGetProperty("tag_name", out var tag) || tag.ValueKind != JsonValueKind.String) return null; + if (!SemanticVersion.TryParse(tag.GetString(), out var version) || version.IsPreRelease) return null; + return new ReleaseInfo(version, $"{ReleasesPageUrl}/tag/v{version}"); + } + catch (JsonException) + { + return null; + } + } + + private static bool IsTrue(JsonElement root, string name) => root.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.True; +} + +/// Asks where the newest release is. Production: ; tests use a fake. +public interface IReleaseFeed +{ + /// The newest release, or null when the answer was not understood. Network failures throw . + Task GetLatestAsync(CancellationToken cancellationToken); +} + +/// +/// One anonymous GET of . The request carries only what GitHub requires (a product name +/// as User-Agent, without the version) and an Accept header: no cookies, no identifiers. The Windows proxy settings are used, with +/// the signed-in user's credentials for a proxy that asks for them, so it works behind a company proxy like the browser does. +/// +public sealed class GitHubReleaseFeed : IReleaseFeed, IDisposable +{ + /// Longest answer read (the real one is a few kilobytes). + public const int MaxAnswerBytes = 512 * 1024; + + private readonly HttpClient _http; + + /// Creates the feed. is for tests; null uses the system proxy settings. + public GitHubReleaseFeed(HttpMessageHandler? handler = null) + { + handler ??= new SocketsHttpHandler + { + UseCookies = false, + UseProxy = true, + // Default proxy = the Windows (WinINET / WinHTTP) settings; the credentials are for proxies that need sign-in. + DefaultProxyCredentials = CredentialCache.DefaultCredentials, + AllowAutoRedirect = true, + MaxAutomaticRedirections = 3, + ConnectTimeout = TimeSpan.FromSeconds(10), + }; + _http = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(20), MaxResponseContentBufferSize = MaxAnswerBytes }; + } + + /// + public async Task GetLatestAsync(CancellationToken cancellationToken) + { + using var request = new HttpRequestMessage(HttpMethod.Get, GitHubRelease.LatestApiUrl); + request.Headers.UserAgent.Add(new ProductInfoHeaderValue("Rotula", null)); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/vnd.github+json")); + using var response = await _http.SendAsync(request, HttpCompletionOption.ResponseContentRead, cancellationToken).ConfigureAwait(false); + if (!response.IsSuccessStatusCode) throw new HttpRequestException($"The release page answered {(int)response.StatusCode}.", null, response.StatusCode); + string json = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + return GitHubRelease.Parse(json); + } + + /// + public void Dispose() => _http.Dispose(); +} + +/// What one update check found. +public enum UpdateCheckStatus +{ + /// The check is switched off; nothing was sent. + Off, + /// The last check is less than a day old; nothing was sent. + NotDue, + /// This is the newest version. + UpToDate, + /// A newer version exists. + Available, + /// No answer (offline, blocked, a wrong answer). Silent: it is tried again the next day. + Failed, +} + +/// The outcome of . +/// What happened. +/// The newer release when is . +/// True when the release page was asked, so the caller records the time of the check. +public sealed record UpdateCheckResult(UpdateCheckStatus Status, ReleaseInfo? Release = null, bool Asked = false); + +/// Decides whether to ask, asks, and compares the answer with the running version. +public sealed class UpdateChecker +{ + private readonly IReleaseFeed _feed; + private readonly SemanticVersion? _current; + + /// Creates the checker. + /// Where the newest release is read. + /// The running version, for example "0.3.5" (build metadata after "+" is ignored). + public UpdateChecker(IReleaseFeed feed, string currentVersion) + { + _feed = feed ?? throw new ArgumentNullException(nameof(feed)); + _current = SemanticVersion.TryParse(currentVersion, out var v) ? v : null; + } + + /// + /// Checks once. Asks nothing when is false or the last check is less than a day old. Never throws + /// for network trouble: a failure is and nobody is bothered with it. + /// + public async Task CheckAsync(bool enabled, DateTimeOffset? lastCheckUtc, DateTimeOffset nowUtc, CancellationToken cancellationToken = default) + { + if (!enabled) return new UpdateCheckResult(UpdateCheckStatus.Off); + if (!UpdateSchedule.IsDue(lastCheckUtc, nowUtc)) return new UpdateCheckResult(UpdateCheckStatus.NotDue); + ReleaseInfo? latest; + try + { + latest = await _feed.GetLatestAsync(cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or IOException or InvalidOperationException) + { + return new UpdateCheckResult(UpdateCheckStatus.Failed, Asked: true); + } + if (latest is null || _current is null) return new UpdateCheckResult(UpdateCheckStatus.Failed, Asked: true); + return latest.Version.CompareTo(_current) > 0 + ? new UpdateCheckResult(UpdateCheckStatus.Available, latest, Asked: true) + : new UpdateCheckResult(UpdateCheckStatus.UpToDate, Asked: true); + } +} diff --git a/src/Rotula.App/Services/WpfServices.cs b/src/Rotula.App/Services/WpfServices.cs index 9047993..0d58720 100644 --- a/src/Rotula.App/Services/WpfServices.cs +++ b/src/Rotula.App/Services/WpfServices.cs @@ -290,3 +290,23 @@ public void ShowAbout(string title, string body) MessageDialogWindow.Show(MessageKind.Info, title, body, new DialogChoice(Loc.Get("Common.Close"), IsPrimary: true, IsDefault: true, IsCancel: true)); } } + +/// Hands a web address to the default browser. Rotula itself downloads nothing. +public sealed class ShellUrlLauncher : IUrlLauncher +{ + /// + public bool Open(string url) + { + // Only https addresses: the update notice builds its own link, but a launcher must never start a program or a file. + if (!Uri.TryCreate(url, UriKind.Absolute, out var uri) || uri.Scheme != Uri.UriSchemeHttps) return false; + try + { + System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(uri.AbsoluteUri) { UseShellExecute = true }); + return true; + } + catch (Exception ex) when (ex is System.ComponentModel.Win32Exception or InvalidOperationException) + { + return false; + } + } +} diff --git a/src/Rotula.App/ViewModels/MainViewModel.cs b/src/Rotula.App/ViewModels/MainViewModel.cs index 506f1d5..e155f8b 100644 --- a/src/Rotula.App/ViewModels/MainViewModel.cs +++ b/src/Rotula.App/ViewModels/MainViewModel.cs @@ -5,6 +5,7 @@ using CommunityToolkit.Mvvm.Input; using Rotula.App.Editing; using Rotula.Files; +using Rotula.Files.Policy; using Rotula.App.Localization; using Rotula.App.Services; using Rotula.Core.Elements; @@ -79,6 +80,11 @@ public sealed record ToolboxItem(ToolboxKind Kind, string NameKey, string Glyph) } /// The services the main view-model depends on. +/// +/// is what the organization fixed through Group Policy or Intune (null: nothing managed); +/// is the opt-in update check (null: no update check at all, as in tests that do not ask for one); +/// opens the release page; is the time source of the daily check (null: the system clock). +/// public sealed record MainServices( IFileDialogService Files, IMessageService Messages, @@ -90,7 +96,11 @@ public sealed record MainServices( ISettingsStore Settings, IToolDialogService Dialogs, IDatabaseServices? Database = null, - IVariableServices? Variables = null); + IVariableServices? Variables = null, + RotulaPolicy? Policy = null, + UpdateChecker? Updates = null, + IUrlLauncher? Links = null, + TimeProvider? Clock = null); /// /// View-model of the main window: the open label, its files, editing commands, view settings (zoom, unit, grid), @@ -109,6 +119,7 @@ public sealed partial class MainViewModel : ObservableObject private readonly MainServices _svc; private readonly AppSettings _settings; + private readonly RotulaPolicy _policy; private readonly List _allCommands = new(); private bool _syncingSelection; @@ -117,7 +128,8 @@ public MainViewModel(MainServices services, EditorSession? session = null) { _svc = services; _settings = services.Settings.Load(); - Loc.Instance.SetLanguage(_settings.Language); + _policy = services.Policy ?? RotulaPolicy.None; + Loc.Instance.SetLanguage(_settings.LanguageIn(_policy)); Session = session ?? new EditorSession(); Commands = new EditorCommands(Session); // Without a counter store (tests, headless use) a counter that remembers its value refuses to print instead of repeating numbers. @@ -128,7 +140,8 @@ public MainViewModel(MainServices services, EditorSession? session = null) ConnectFormatting(); // Handles and selection frames are measured with the record shown on the canvas, not only the sample values. Session.Bounds.DataProvider = () => Preview.Data; - Printer = new PrinterSettingsViewModel(services.Printing, _settings.Print, SaveSettings, () => Session.Document, applyLabelDpi: SetLabelDpi); + Printer = new PrinterSettingsViewModel(services.Printing, _settings.Print, SaveSettings, () => Session.Document, applyLabelDpi: SetLabelDpi, + defaultPrinter: _policy.DefaultPrinter); Data = new DataMergeViewModel(services.Printing, Printer, () => Session.Document, () => DocumentName, services.Files, services.Messages, services.Database, services.Dialogs, Variables); Data.SettingsEdited += (_, _) => Session.MarkDirty(); Data.PreviewDataChanged += (_, _) => OnPreviewDataChanged(); @@ -379,8 +392,66 @@ partial void OnUnitChanged(LengthUnit value) /// Grid configuration for the canvas. public GridSettings Grid => new(LengthUnit.Millimeters, _settings.GridSpacingMm, GridVisible, SnapToGrid); - /// The language setting: "auto", "en" or "es". - public string LanguageSetting => _settings.Language; + /// The language setting in use: "auto", "en" or "es" (the organization's choice when a policy sets one). + public string LanguageSetting => _settings.LanguageIn(_policy); + + /// True when the organization fixed the language; the menu entries are then disabled with a note. + public bool IsLanguageManaged => _policy.IsLanguageManaged; + + /// True when the user may choose the language. + public bool CanChooseLanguage => !IsLanguageManaged; + + /// + /// The daily update check (Help menu). Off by default; switching it on starts the first check at once. When a policy + /// switches it on or off the menu entry is disabled and shows the policy's value. + /// + public bool UpdateCheckEnabled + { + get => _settings.UpdateCheckIn(_policy); + set + { + if (IsUpdateCheckManaged || value == _settings.UpdateCheckEnabled) return; + _settings.UpdateCheckEnabled = value; + SaveSettings(); + OnPropertyChanged(); + if (value) _ = CheckForUpdatesAsync(); + else UpdateNotice = null; + } + } + + /// True when the organization decides about the update check. + public bool IsUpdateCheckManaged => _policy.IsUpdateCheckManaged; + + /// True when the user may switch the update check on or off. + public bool CanChangeUpdateCheck => _svc.Updates is not null && !IsUpdateCheckManaged; + + /// The "newer version available" notice; null while there is nothing to say. + [ObservableProperty] + private UpdateNoticeViewModel? _updateNotice; + + /// + /// Runs the update check when it is switched on and the last one is a day old or more. The time is recorded before asking, + /// so a computer without internet access tries once a day, not at every start. Failures stay silent. The notice is not shown + /// again for a version whose notice the user closed. + /// + public async Task CheckForUpdatesAsync(CancellationToken cancellationToken = default) + { + if (_svc.Updates is not { } checker) return; + var now = (_svc.Clock ?? TimeProvider.System).GetUtcNow(); + bool enabled = _settings.UpdateCheckIn(_policy); + if (!enabled || !UpdateSchedule.IsDue(_settings.LastUpdateCheckUtc, now)) return; + _settings.LastUpdateCheckUtc = now; + SaveSettings(); + // Due-ness was decided above (before the time was recorded), so the checker is told there was no earlier check. + var result = await checker.CheckAsync(enabled, lastCheckUtc: null, now, cancellationToken); + if (result is not { Status: UpdateCheckStatus.Available, Release: { } release }) return; + // Switched off while the answer was on its way: say nothing. + if (!_settings.UpdateCheckIn(_policy)) return; + if (string.Equals(_settings.UpdateDismissedVersion, release.Version.ToString(), StringComparison.OrdinalIgnoreCase)) return; + UpdateNotice = new UpdateNoticeViewModel(release, url => _svc.Links?.Open(url) ?? false, + version => { _settings.UpdateDismissedVersion = version; SaveSettings(); }, + text => StatusMessage = text); + } /// Menu check state for the language entries. public bool IsLanguageAuto => LanguageSetting == "auto"; @@ -1282,10 +1353,11 @@ private bool ChooseImageFor(string elementId) [RelayCommand] private void ToggleGrid() => GridVisible = !GridVisible; - /// Switch the language: "auto", "en" or "es". The change is live; no restart is needed. - [RelayCommand] + /// Switch the language: "auto", "en" or "es". The change is live; no restart is needed. Disabled when a policy fixes the language. + [RelayCommand(CanExecute = nameof(CanChooseLanguage))] private void SetLanguage(string code) { + if (IsLanguageManaged) return; _settings.Language = code is "en" or "es" ? code : "auto"; Loc.Instance.SetLanguage(_settings.Language); // SetLanguage does nothing when the resolved language is unchanged (auto -> en on an English PC), but the menu diff --git a/src/Rotula.App/ViewModels/PrinterSettingsViewModel.cs b/src/Rotula.App/ViewModels/PrinterSettingsViewModel.cs index e17185b..d26af0a 100644 --- a/src/Rotula.App/ViewModels/PrinterSettingsViewModel.cs +++ b/src/Rotula.App/ViewModels/PrinterSettingsViewModel.cs @@ -36,6 +36,7 @@ public sealed partial class PrinterSettingsViewModel : ObservableObject /// The resolution the driver of the selected printer reported, or null when nothing was read (not asked yet, failed, or a network printer). public PrinterDpi? DetectedDpi => _detected.Dpi; private IReadOnlyList _printers = Array.Empty(); + private readonly string? _defaultPrinter; /// Creates the view-model. /// Printing service (used to list printers). @@ -44,9 +45,14 @@ public sealed partial class PrinterSettingsViewModel : ObservableObject /// The open label; its size and resolution seed the profile the first time. /// Reads the driver resolution and paper size of a printer; null uses (tests pass a fake). /// Switches the open label to a resolution (one undo step); null disables the "Switch label" action. + /// + /// A Windows printer chosen by the organization's policy, preselected until the user has picked a printer once; null for none. + /// public PrinterSettingsViewModel(IPrintService service, PrintSettings settings, Action save, Func document, - Func>? detect = null, Action? applyLabelDpi = null) + Func>? detect = null, Action? applyLabelDpi = null, + string? defaultPrinter = null) { + _defaultPrinter = string.IsNullOrWhiteSpace(defaultPrinter) ? null : defaultPrinter.Trim(); _detect = detect ?? ((name, ct) => PrinterDetector.DetectAsync(service, name, ct)); _applyLabelDpi = applyLabelDpi; _service = service; @@ -476,6 +482,7 @@ private void LoadFromSettings() HeightText = FormatMm(ToMm(doc.Height, doc.Dpi)); SelectedMedia = doc.MediaTracking; LoadStatusChoice(); + if (_defaultPrinter is not null) SelectedPrinterName = _defaultPrinter; return; } UseNetwork = string.Equals(s.Transport, "Tcp", StringComparison.OrdinalIgnoreCase); @@ -525,7 +532,8 @@ public async Task RefreshPrintersAsync(CancellationToken cancellationToken = def } if (SelectedPrinterName is { } saved && !found.Any(p => p.Name == saved)) { - PrintersNote = Loc.Get("Print.Printers.Missing", saved); + // The organization's default printer is only a suggestion: when this computer does not have it, fall back quietly. + if (!(saved == _defaultPrinter && !_settings.Configured)) PrintersNote = Loc.Get("Print.Printers.Missing", saved); SelectedPrinterName = null; } if (SelectedPrinterName is null && !UseNetwork) diff --git a/src/Rotula.App/ViewModels/UpdateNoticeViewModel.cs b/src/Rotula.App/ViewModels/UpdateNoticeViewModel.cs new file mode 100644 index 0000000..36ad15f --- /dev/null +++ b/src/Rotula.App/ViewModels/UpdateNoticeViewModel.cs @@ -0,0 +1,56 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Rotula.App.Localization; +using Rotula.App.Services; + +namespace Rotula.App.ViewModels; + +/// +/// The non-modal "a newer version is available" strip under the toolbar. It only links to the release page; Rotula never +/// downloads or installs anything itself. +/// +public sealed partial class UpdateNoticeViewModel : ObservableObject +{ + private readonly Func _open; + private readonly Action _dismissed; + private readonly Action _status; + + /// Creates the notice. + /// The newer release. + /// Opens a web address; returns false when that failed. + /// Called with the version when the user closes the notice (so it is not shown again for that version). + /// Shows a sentence in the status bar. + public UpdateNoticeViewModel(ReleaseInfo release, Func open, Action dismissed, Action status) + { + Release = release ?? throw new ArgumentNullException(nameof(release)); + _open = open; + _dismissed = dismissed; + _status = status; + Loc.Instance.SubscribeWeak(this, static self => self.OnPropertyChanged(nameof(NoticeText))); + } + + /// The newer release. + public ReleaseInfo Release { get; } + + /// The sentence of the notice, in the current language. + public string NoticeText => Loc.Get("Update.Notice", Release.Version.ToString()); + + /// False once the user closed the notice. + [ObservableProperty] + private bool _isVisible = true; + + /// Opens the release page in the browser. + [RelayCommand] + private void OpenRelease() + { + if (!_open(Release.PageUrl)) _status(Loc.Get("Update.OpenFailed", Release.PageUrl)); + } + + /// Hides the notice and remembers that this version was seen. + [RelayCommand] + private void Dismiss() + { + IsVisible = false; + _dismissed(Release.Version.ToString()); + } +} diff --git a/src/Rotula.Files/Policy/PolicySources.cs b/src/Rotula.Files/Policy/PolicySources.cs new file mode 100644 index 0000000..a1c6242 --- /dev/null +++ b/src/Rotula.Files/Policy/PolicySources.cs @@ -0,0 +1,107 @@ +using Microsoft.Win32; + +namespace Rotula.Files.Policy; + +/// One level of policy values: a registry key in production, a dictionary in tests. +public interface IPolicySource +{ + /// The text value , or null when it is not set (or is not text). + string? GetString(string name); + + /// The whole-number value , or null when it is not set (or is not a number). + int? GetInt(string name); +} + +/// A source with no values: no policy is set. +public sealed class EmptyPolicySource : IPolicySource +{ + /// The shared instance. + public static readonly EmptyPolicySource Instance = new(); + + private EmptyPolicySource() { } + + /// + public string? GetString(string name) => null; + + /// + public int? GetInt(string name) => null; +} + +/// +/// Two sources read value by value: a value set for the computer wins over the same value set for the user, and a value the +/// computer does not set falls back to the user's. This is how Windows itself treats HKLM and HKCU policies. +/// +public sealed class LayeredPolicySource : IPolicySource +{ + private readonly IPolicySource _machine; + private readonly IPolicySource _user; + + /// Creates the layered source. + /// The computer-wide values (HKLM); they win. + /// The per-user values (HKCU). + public LayeredPolicySource(IPolicySource machine, IPolicySource user) + { + _machine = machine ?? throw new ArgumentNullException(nameof(machine)); + _user = user ?? throw new ArgumentNullException(nameof(user)); + } + + /// + public string? GetString(string name) => _machine.GetString(name) ?? _user.GetString(name); + + /// + public int? GetInt(string name) => _machine.GetInt(name) ?? _user.GetInt(name); +} + +/// Where a policy value is set. +public enum PolicyScope +{ + /// For every user of the computer (HKEY_LOCAL_MACHINE). + Computer, + /// For the signed-in user (HKEY_CURRENT_USER). + User, +} + +/// +/// Reads under one registry hive. Read-only; a missing key, a value of the wrong type or a +/// key the user may not read all mean "not set", never an error. Off Windows every value is "not set". +/// +public sealed class RegistryPolicySource : IPolicySource +{ + private readonly PolicyScope _scope; + + /// Creates a source for the computer-wide (HKLM) or the per-user (HKCU) policies. + public RegistryPolicySource(PolicyScope scope) => _scope = scope; + + /// The policies of this computer: HKLM first, then HKCU, value by value. + public static IPolicySource ForThisComputer() => + new LayeredPolicySource(new RegistryPolicySource(PolicyScope.Computer), new RegistryPolicySource(PolicyScope.User)); + + /// + public string? GetString(string name) => Read(name) as string; + + /// + public int? GetInt(string name) => Read(name) switch + { + int i => i, + // A QWORD written by a script instead of the template: accept it when it fits. + long l when l is >= int.MinValue and <= int.MaxValue => (int)l, + _ => null, + }; + + private object? Read(string name) + { + if (!OperatingSystem.IsWindows()) return null; + try + { + // Registry64 on purpose: the templates write to the 64-bit view, and a policy must not depend on the process bitness. + var hive = _scope == PolicyScope.Computer ? RegistryHive.LocalMachine : RegistryHive.CurrentUser; + using var root = RegistryKey.OpenBaseKey(hive, RegistryView.Registry64); + using var key = root.OpenSubKey(PolicyNames.KeyPath, writable: false); + return key?.GetValue(name); + } + catch (Exception ex) when (ex is System.Security.SecurityException or UnauthorizedAccessException or IOException) + { + return null; + } + } +} diff --git a/src/Rotula.Files/Policy/RotulaPolicy.cs b/src/Rotula.Files/Policy/RotulaPolicy.cs new file mode 100644 index 0000000..59d1bb5 --- /dev/null +++ b/src/Rotula.Files/Policy/RotulaPolicy.cs @@ -0,0 +1,88 @@ +namespace Rotula.Files.Policy; + +/// +/// Registry location and value names of the Rotula policies. The ADMX template in packaging/policies writes exactly +/// these names (a test compares them), and docs/deployment.md documents them. +/// +public static class PolicyNames +{ + /// Key under HKLM (computer) and HKCU (user). The computer value wins. + public const string KeyPath = @"SOFTWARE\Policies\P4Software\Rotula"; + + /// Text: "auto", "en" or "es". Sets the language and locks the menu. + public const string Language = "Language"; + + /// Number: 1 turns the update check on, 0 turns it off; either way the user cannot change it. + public const string UpdateCheck = "UpdateCheck"; + + /// Text: Windows printer name preselected until the user picks a printer. Not locked. + public const string DefaultPrinter = "DefaultPrinter"; + + /// Number: 0 means add-ons in the user's own folder are not loaded (the installed "packs" folder still is). + public const string UserAddOns = "UserAddOns"; + + /// Number: 0 means the MCP server never prints, whatever its settings file says. + public const string McpPrinting = "McpPrinting"; + + /// Number: 0 means the MCP server never reads databases, whatever its settings file says. + public const string McpDatabase = "McpDatabase"; + + /// Every value name, for the template check. + public static readonly IReadOnlyList All = new[] { Language, UpdateCheck, DefaultPrinter, UserAddOns, McpPrinting, McpDatabase }; +} + +/// +/// The settings an organization has fixed through Group Policy or Intune. A null value means "not managed": the user decides. +/// A policy can choose a value for the user or take a feature away; it never turns on a risky feature (MCP printing and +/// database access still need the MCP settings file as well). +/// +public sealed record RotulaPolicy +{ + /// No policy at all. + public static readonly RotulaPolicy None = new(); + + /// "auto", "en" or "es" when the language is managed; null otherwise. + public string? Language { get; init; } + + /// True or false when the update check is managed; null when the user decides. + public bool? UpdateCheck { get; init; } + + /// A Windows printer name to start from when the user has not chosen a printer yet; null when not set. + public string? DefaultPrinter { get; init; } + + /// False when add-ons in the user's own folder must not be loaded. + public bool AllowUserAddOns { get; init; } = true; + + /// True when the MCP server must refuse to print. + public bool McpPrintingBlocked { get; init; } + + /// True when the MCP server must refuse database access. + public bool McpDatabaseBlocked { get; init; } + + /// True when the language is chosen by the organization. + public bool IsLanguageManaged => Language is not null; + + /// True when the update check is switched on or off by the organization. + public bool IsUpdateCheckManaged => UpdateCheck is not null; + + /// + /// Reads every policy value once. Values that are not understood (an unknown language, a number other than 0 or 1, a printer + /// name that is too long) are ignored, so a typo in a policy never forces a wrong value on the user. + /// + public static RotulaPolicy Read(IPolicySource source) + { + ArgumentNullException.ThrowIfNull(source); + string? language = source.GetString(PolicyNames.Language)?.Trim().ToLowerInvariant(); + string? printer = source.GetString(PolicyNames.DefaultPrinter)?.Trim(); + return new RotulaPolicy + { + Language = language is "auto" or "en" or "es" ? language : null, + UpdateCheck = source.GetInt(PolicyNames.UpdateCheck) switch { 0 => false, 1 => true, _ => null }, + // Windows printer names are at most 220 characters; anything longer is not a printer name. + DefaultPrinter = string.IsNullOrEmpty(printer) || printer.Length > 220 ? null : printer, + AllowUserAddOns = source.GetInt(PolicyNames.UserAddOns) != 0, + McpPrintingBlocked = source.GetInt(PolicyNames.McpPrinting) == 0, + McpDatabaseBlocked = source.GetInt(PolicyNames.McpDatabase) == 0, + }; + } +} diff --git a/src/Rotula.Mcp/Program.cs b/src/Rotula.Mcp/Program.cs index 265f623..e3d04de 100644 --- a/src/Rotula.Mcp/Program.cs +++ b/src/Rotula.Mcp/Program.cs @@ -15,7 +15,9 @@ public static async Task Main() { string settingsPath = Path.Combine(AppContext.BaseDirectory, McpSettingsLoader.FileName); string documents = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments); - var loaded = McpSettingsLoader.Load(settingsPath, documents); + // Group Policy / Intune can switch printing and database access off for every user of the computer (docs/deployment.md). + var loaded = McpSettingsLoader.ApplyPolicy(McpSettingsLoader.Load(settingsPath, documents), + Rotula.Files.Policy.RotulaPolicy.Read(Rotula.Files.Policy.RegistryPolicySource.ForThisComputer())); // Started by a person (double-click or a console) instead of by an AI assistant application: explain, then wait. if (!Console.IsInputRedirected) @@ -64,8 +66,8 @@ private static void ShowHelp(McpSettingsLoadResult loaded) Console.WriteLine(); Console.WriteLine("Settings file (folders, printing, databases): " + loaded.Path); Console.WriteLine("Allowed label folders: " + string.Join("; ", loaded.Settings.AllowedFolders)); - Console.WriteLine("Printing: " + (loaded.Settings.AllowPrinting ? "allowed for the listed printers" : "off")); - Console.WriteLine("Databases: " + (loaded.Settings.AllowDatabase ? "on (read-only)" : "off")); + Console.WriteLine("Printing: " + (loaded.Settings.AllowPrinting ? "allowed for the listed printers" : loaded.Settings.PrintingBlockedByPolicy ? "off (organization policy)" : "off")); + Console.WriteLine("Databases: " + (loaded.Settings.AllowDatabase ? "on (read-only)" : loaded.Settings.DatabaseBlockedByPolicy ? "off (organization policy)" : "off")); foreach (var note in loaded.Notes) Console.WriteLine("Note: " + note); Console.WriteLine(); Console.WriteLine("Press Enter to close this window."); diff --git a/src/Rotula.Mcp/Security/PrintGate.cs b/src/Rotula.Mcp/Security/PrintGate.cs index ec7b814..ae561d0 100644 --- a/src/Rotula.Mcp/Security/PrintGate.cs +++ b/src/Rotula.Mcp/Security/PrintGate.cs @@ -52,6 +52,8 @@ public PrintGate(McpSettings settings, Func? clock = null) /// A refusal, or null when the request may proceed. public GateResult? CheckPolicy(string? printer, int copies) { + if (_settings.PrintingBlockedByPolicy) + return Refuse("Printing is switched off by your organization's policy for this computer. Ask your IT department. Nothing was sent."); if (!_settings.AllowPrinting) return Refuse("Printing is switched off. The person who installed the server can allow it by setting allowPrinting to true " + "and listing the printer in allowedPrinters in rotula-mcp.settings.json. Nothing was sent."); diff --git a/src/Rotula.Mcp/Settings/McpSettings.cs b/src/Rotula.Mcp/Settings/McpSettings.cs index a4d212f..94fa953 100644 --- a/src/Rotula.Mcp/Settings/McpSettings.cs +++ b/src/Rotula.Mcp/Settings/McpSettings.cs @@ -51,6 +51,12 @@ public sealed record McpSettings /// When true the log also records tool arguments; label contents then reach the log file. Off by default. public bool DebugLogging { get; init; } + + /// True when an organization policy switched printing off (see ). + public bool PrintingBlockedByPolicy { get; init; } + + /// True when an organization policy switched database access off. + public bool DatabaseBlockedByPolicy { get; init; } } /// The outcome of loading the settings: the values plus plain-language notes for the person running the server. diff --git a/src/Rotula.Mcp/Settings/McpSettingsLoader.cs b/src/Rotula.Mcp/Settings/McpSettingsLoader.cs index abc0725..8066a28 100644 --- a/src/Rotula.Mcp/Settings/McpSettingsLoader.cs +++ b/src/Rotula.Mcp/Settings/McpSettingsLoader.cs @@ -1,5 +1,6 @@ using System.Text; using System.Text.Json; +using Rotula.Files.Policy; namespace Rotula.Mcp.Settings; @@ -63,6 +64,31 @@ public static McpSettingsLoadResult Load(string settingsPath, string documentsFo return new McpSettingsLoadResult(settings, notes, settingsPath); } + /// + /// Applies the organization's policy on top of the settings file. A policy can only switch printing or database access off, + /// never on: the settings file must still allow them, so an administrator cannot widen what the person who set up the server chose. + /// + /// The result of . + /// The policy read from the registry. + public static McpSettingsLoadResult ApplyPolicy(McpSettingsLoadResult loaded, RotulaPolicy policy) + { + ArgumentNullException.ThrowIfNull(loaded); + ArgumentNullException.ThrowIfNull(policy); + var settings = loaded.Settings; + var notes = loaded.Notes.ToList(); + if (policy.McpPrintingBlocked) + { + if (settings.AllowPrinting) notes.Add("Printing is switched off by your organization's policy, although allowPrinting is true in the settings file."); + settings = settings with { AllowPrinting = false, PrintingBlockedByPolicy = true }; + } + if (policy.McpDatabaseBlocked) + { + if (settings.AllowDatabase) notes.Add("Database access is switched off by your organization's policy, although allowDatabase is true in the settings file."); + settings = settings with { AllowDatabase = false, DatabaseBlockedByPolicy = true }; + } + return new McpSettingsLoadResult(settings, notes, loaded.Path); + } + private static McpSettings Parse(string path, List notes) { try diff --git a/src/Rotula.Mcp/Tools/DataTools.cs b/src/Rotula.Mcp/Tools/DataTools.cs index 8b9e5bd..b0036f2 100644 --- a/src/Rotula.Mcp/Tools/DataTools.cs +++ b/src/Rotula.Mcp/Tools/DataTools.cs @@ -61,6 +61,8 @@ internal static QueryResult RunQuery(McpContext ctx, string connection, string q internal static void RequireEnabled(McpContext ctx) { + if (ctx.Settings.DatabaseBlockedByPolicy) + throw new McpException("Database access is switched off by your organization's policy for this computer. Ask your IT department."); if (!ctx.Settings.AllowDatabase) throw new McpException("Database access is switched off. The person who installed the server can allow it by setting allowDatabase to true in rotula-mcp.settings.json."); } diff --git a/tests/Rotula.Tests/AppLocalizationTests.cs b/tests/Rotula.Tests/AppLocalizationTests.cs index 8195af0..6698bcc 100644 --- a/tests/Rotula.Tests/AppLocalizationTests.cs +++ b/tests/Rotula.Tests/AppLocalizationTests.cs @@ -66,7 +66,7 @@ public void EveryKeyUsedInXamlOrCode_ExistsInBothLanguages() used.Add(m.Groups[1].Value); // Literals in code that look like resource keys. Comment lines are skipped (they quote member names). - var keyShape = new Regex("\"((?:Prop|Panel|Menu|Tool|Status|Dialog|File|Undo|Element|Validation|Preview|Preset|Import|Print|Image|Issue|Document|Encoding|Layers|About|Ruler|Zpl|Toolbar|Canvas|Common|Crash|Filter|Splitter|Data|Conn|Stock|Pack|Fields|Station)(?:\\.[A-Za-z0-9]+)+)\""); + var keyShape = new Regex("\"((?:Prop|Panel|Menu|Tool|Status|Dialog|File|Undo|Element|Validation|Preview|Preset|Import|Print|Image|Issue|Document|Encoding|Layers|About|Ruler|Zpl|Toolbar|Canvas|Common|Crash|Filter|Splitter|Data|Conn|Stock|Pack|Fields|Station|Policy|Update)(?:\\.[A-Za-z0-9]+)+)\""); // The .rotula serializer lives in Rotula.Files (moved out of the app); its File.Error.* keys must stay covered. var filesLib = Path.Combine(RepoRoot(), "src", "Rotula.Files"); foreach (var cs in Directory.EnumerateFiles(src, "*.cs", SearchOption.AllDirectories).Concat(Directory.EnumerateFiles(filesLib, "*.cs", SearchOption.AllDirectories)).Where(f => !f.Contains(@"\obj\") && !f.Contains(@"\bin\"))) diff --git a/tests/Rotula.Tests/AppUpdateCheckTests.cs b/tests/Rotula.Tests/AppUpdateCheckTests.cs new file mode 100644 index 0000000..1ff3e1b --- /dev/null +++ b/tests/Rotula.Tests/AppUpdateCheckTests.cs @@ -0,0 +1,404 @@ +using System.Net; +using System.Net.Http; +using Rotula.App.Localization; +using Rotula.App.Services; +using Rotula.App.ViewModels; +using Rotula.Files.Policy; +using Rotula.Printing; + +namespace Rotula.Tests; + +/// The release feed of the tests: counts the calls and answers what the test says. +internal sealed class FakeReleaseFeed : IReleaseFeed +{ + public int Calls; + public ReleaseInfo? Answer; + public Exception? Failure; + + public static ReleaseInfo Release(string version) + { + Assert.True(SemanticVersion.TryParse(version, out var v)); + return new ReleaseInfo(v, $"{GitHubRelease.ReleasesPageUrl}/tag/v{v}"); + } + + public Task GetLatestAsync(CancellationToken cancellationToken) + { + Calls++; + return Failure is null ? Task.FromResult(Answer) : Task.FromException(Failure); + } +} + +/// A clock the test moves by hand. +internal sealed class ManualClock : TimeProvider +{ + public DateTimeOffset Now = new(2026, 10, 24, 9, 0, 0, TimeSpan.Zero); + public override DateTimeOffset GetUtcNow() => Now; +} + +/// The version compare, the daily schedule, the answer parser and the checker: all UI-free. +public class UpdateCheckLogicTests +{ + private static SemanticVersion V(string s) + { + Assert.True(SemanticVersion.TryParse(s, out var v), s); + return v; + } + + [Theory] + [InlineData("0.3.5", "0.3.6")] + [InlineData("0.3.5", "0.4.0")] + [InlineData("0.9.9", "1.0.0")] + [InlineData("0.3.9", "0.3.10")] // numeric, not alphabetical + [InlineData("1.0.0-rc1", "1.0.0")] // a release is newer than its pre-release + [InlineData("1.0.0-alpha", "1.0.0-alpha.1")] + [InlineData("1.0.0-alpha.1", "1.0.0-alpha.beta")] + [InlineData("1.0.0-alpha.beta", "1.0.0-beta")] + [InlineData("1.0.0-beta.2", "1.0.0-beta.11")] + [InlineData("1.0.0-beta.11", "1.0.0-rc.1")] + public void Older_IsBelowNewer(string older, string newer) + { + Assert.True(V(older).CompareTo(V(newer)) < 0); + Assert.True(V(newer).CompareTo(V(older)) > 0); + } + + [Theory] + [InlineData("v1.2.3", "1.2.3")] + [InlineData("1.2.3+build.7", "1.2.3")] + [InlineData("0.3.5+8ee3037d", "0.3.5")] // the informational version of a local build + public void PrefixAndBuildMetadata_AreIgnored(string a, string b) + { + Assert.Equal(0, V(a).CompareTo(V(b))); + Assert.Equal(V(b).ToString(), V(a).ToString()); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("1.2")] + [InlineData("1.2.3.4")] + [InlineData("1.x.3")] + [InlineData("latest")] + [InlineData("1.2.3-")] + [InlineData("1.2.3-a..b")] + [InlineData("1.2.3-