From e2676d784d05ca5c06bfc34ddd58172956bd48bf Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 13:21:27 +0000 Subject: [PATCH 1/5] feat(policy): read Group Policy / Intune settings from the registry Rotula.Files.Policy reads SOFTWARE\Policies\P4Software\Rotula from HKLM and HKCU (the computer wins, value by value) behind IPolicySource. Policies fix the language and the update check, suggest a default printer, keep user-folder add-ons from loading, and switch MCP printing or database access off. The MCP server applies them on top of its settings file and can only be restricted, never widened. ADMX with en-US and es-ES ADML ship in packaging/policies; tests check the template value names against PolicyNames. --- packaging/policies/Rotula.admx | 86 ++++++++ packaging/policies/en-US/Rotula.adml | 78 +++++++ packaging/policies/es-ES/Rotula.adml | 78 +++++++ src/Rotula.App/Services/AppSettings.cs | 22 +- src/Rotula.Files/Policy/PolicySources.cs | 107 +++++++++ src/Rotula.Files/Policy/RotulaPolicy.cs | 88 ++++++++ src/Rotula.Mcp/Program.cs | 8 +- src/Rotula.Mcp/Security/PrintGate.cs | 2 + src/Rotula.Mcp/Settings/McpSettings.cs | 6 + src/Rotula.Mcp/Settings/McpSettingsLoader.cs | 26 +++ src/Rotula.Mcp/Tools/DataTools.cs | 2 + tests/Rotula.Tests/DeploymentPolicyTests.cs | 216 +++++++++++++++++++ 12 files changed, 715 insertions(+), 4 deletions(-) create mode 100644 packaging/policies/Rotula.admx create mode 100644 packaging/policies/en-US/Rotula.adml create mode 100644 packaging/policies/es-ES/Rotula.adml create mode 100644 src/Rotula.Files/Policy/PolicySources.cs create mode 100644 src/Rotula.Files/Policy/RotulaPolicy.cs create mode 100644 tests/Rotula.Tests/DeploymentPolicyTests.cs 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/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.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/DeploymentPolicyTests.cs b/tests/Rotula.Tests/DeploymentPolicyTests.cs new file mode 100644 index 0000000..52375be --- /dev/null +++ b/tests/Rotula.Tests/DeploymentPolicyTests.cs @@ -0,0 +1,216 @@ +using System.Xml.Linq; +using Rotula.Files.Policy; +using Rotula.Mcp.Security; +using Rotula.Mcp.Settings; + +namespace Rotula.Tests; + +/// A policy source made of two dictionaries, standing in for the HKLM and HKCU keys. +internal sealed class FakePolicySource : IPolicySource +{ + public readonly Dictionary Values = new(StringComparer.OrdinalIgnoreCase); + + public FakePolicySource Set(string name, object value) { Values[name] = value; return this; } + + public string? GetString(string name) => Values.TryGetValue(name, out var v) ? v as string : null; + + public int? GetInt(string name) => Values.TryGetValue(name, out var v) && v is int i ? i : null; +} + +/// Organization policies: reading, precedence, the MCP server's use of them, and the ADMX/ADML templates. +public class DeploymentPolicyTests +{ + [Fact] + public void NoPolicy_ManagesNothing() + { + var p = RotulaPolicy.Read(EmptyPolicySource.Instance); + Assert.Equal(RotulaPolicy.None, p); + Assert.Null(p.Language); + Assert.Null(p.UpdateCheck); + Assert.Null(p.DefaultPrinter); + Assert.True(p.AllowUserAddOns); + Assert.False(p.McpPrintingBlocked); + Assert.False(p.McpDatabaseBlocked); + Assert.False(p.IsLanguageManaged); + Assert.False(p.IsUpdateCheckManaged); + } + + [Fact] + public void EveryValueIsRead() + { + var src = new FakePolicySource() + .Set(PolicyNames.Language, " ES ") + .Set(PolicyNames.UpdateCheck, 0) + .Set(PolicyNames.DefaultPrinter, " Warehouse ZD420 ") + .Set(PolicyNames.UserAddOns, 0) + .Set(PolicyNames.McpPrinting, 0) + .Set(PolicyNames.McpDatabase, 0); + var p = RotulaPolicy.Read(src); + Assert.Equal("es", p.Language); + Assert.False(p.UpdateCheck); + Assert.Equal("Warehouse ZD420", p.DefaultPrinter); + Assert.False(p.AllowUserAddOns); + Assert.True(p.McpPrintingBlocked); + Assert.True(p.McpDatabaseBlocked); + Assert.True(p.IsLanguageManaged); + Assert.True(p.IsUpdateCheckManaged); + } + + [Theory] + [InlineData("fr")] + [InlineData("")] + [InlineData("english")] + public void AnUnknownLanguage_IsIgnored(string value) => + Assert.Null(RotulaPolicy.Read(new FakePolicySource().Set(PolicyNames.Language, value)).Language); + + [Theory] + [InlineData(1, true)] + [InlineData(0, false)] + [InlineData(2, null)] + [InlineData(-1, null)] + public void UpdateCheck_OnlyZeroAndOneMeanSomething(int value, bool? expected) => + Assert.Equal(expected, RotulaPolicy.Read(new FakePolicySource().Set(PolicyNames.UpdateCheck, value)).UpdateCheck); + + [Fact] + public void WrongTypesAndOverlongPrinterNames_AreIgnored() + { + var p = RotulaPolicy.Read(new FakePolicySource() + .Set(PolicyNames.UpdateCheck, "1") // text instead of a number + .Set(PolicyNames.Language, 1) // number instead of text + .Set(PolicyNames.DefaultPrinter, new string('x', 221)) + .Set(PolicyNames.McpPrinting, 1)); // 1 only means "the settings file decides" + Assert.Null(p.UpdateCheck); + Assert.Null(p.Language); + Assert.Null(p.DefaultPrinter); + Assert.False(p.McpPrintingBlocked); + } + + [Fact] + public void TheComputerWins_ValueByValue() + { + var machine = new FakePolicySource().Set(PolicyNames.Language, "en"); + var user = new FakePolicySource().Set(PolicyNames.Language, "es").Set(PolicyNames.UpdateCheck, 1); + var p = RotulaPolicy.Read(new LayeredPolicySource(machine, user)); + Assert.Equal("en", p.Language); // set in both: HKLM wins + Assert.True(p.UpdateCheck); // only the user sets it: HKCU applies + } + + [Fact] + public void TheRegistrySource_NeverThrows() + { + // On a build machine without the key (and off Windows) every value is simply "not set". + var p = RotulaPolicy.Read(RegistryPolicySource.ForThisComputer()); + Assert.NotNull(p); + } + + [Fact] + public void AppSettings_UseThePolicyValueButKeepTheUsersOwn() + { + var s = new Rotula.App.Services.AppSettings { Language = "en", UpdateCheckEnabled = true }; + var p = new RotulaPolicy { Language = "es", UpdateCheck = false }; + Assert.Equal("es", s.LanguageIn(p)); + Assert.False(s.UpdateCheckIn(p)); + Assert.Equal("en", s.LanguageIn(RotulaPolicy.None)); + Assert.True(s.UpdateCheckIn(RotulaPolicy.None)); + // Forced on by the organization, although the user never switched it on. + Assert.True(new Rotula.App.Services.AppSettings().UpdateCheckIn(new RotulaPolicy { UpdateCheck = true })); + } + + // ------------------------------------------------------------------------------------------------- MCP server + + private static McpSettingsLoadResult Loaded(McpSettings s) => new(s, Array.Empty(), "rotula-mcp.settings.json"); + + [Fact] + public void Mcp_APolicySwitchesPrintingAndDatabasesOff_AndSaysSo() + { + var open = new McpSettings { AllowPrinting = true, AllowedPrinters = ["Zebra ZD420"], AllowDatabase = true }; + var r = McpSettingsLoader.ApplyPolicy(Loaded(open), new RotulaPolicy { McpPrintingBlocked = true, McpDatabaseBlocked = true }); + Assert.False(r.Settings.AllowPrinting); + Assert.False(r.Settings.AllowDatabase); + Assert.True(r.Settings.PrintingBlockedByPolicy); + Assert.True(r.Settings.DatabaseBlockedByPolicy); + Assert.Contains(r.Notes, n => n.Contains("Printing is switched off by your organization's policy")); + Assert.Contains(r.Notes, n => n.Contains("Database access is switched off by your organization's policy")); + + var gate = new PrintGate(r.Settings).Evaluate("Zebra ZD420", 1, "fp", null); + Assert.Equal(GateDecision.Refused, gate.Decision); + Assert.Contains("organization's policy", gate.Message); + } + + [Fact] + public void Mcp_APolicyNeverSwitchesAnythingOn() + { + var closed = new McpSettings(); + var r = McpSettingsLoader.ApplyPolicy(Loaded(closed), RotulaPolicy.Read(new FakePolicySource() + .Set(PolicyNames.McpPrinting, 1).Set(PolicyNames.McpDatabase, 1))); + Assert.False(r.Settings.AllowPrinting); + Assert.False(r.Settings.AllowDatabase); + Assert.False(r.Settings.PrintingBlockedByPolicy); + Assert.Empty(r.Notes); + + var open = new McpSettings { AllowPrinting = true, AllowDatabase = true }; + var kept = McpSettingsLoader.ApplyPolicy(Loaded(open), RotulaPolicy.None); + Assert.True(kept.Settings.AllowPrinting); + Assert.True(kept.Settings.AllowDatabase); + } + + // ------------------------------------------------------------------------------------------------- templates + + private static string RepoRoot() + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "Rotula.sln"))) dir = dir.Parent; + return dir?.FullName ?? throw new InvalidOperationException("Rotula.sln not found above the test folder."); + } + + private static readonly XNamespace Gp = "http://schemas.microsoft.com/GroupPolicy/2006/07/PolicyDefinitions"; + + private static XDocument Admx() => XDocument.Load(Path.Combine(RepoRoot(), "packaging", "policies", "Rotula.admx")); + + private static XDocument Adml(string culture) => XDocument.Load(Path.Combine(RepoRoot(), "packaging", "policies", culture, "Rotula.adml")); + + [Fact] + public void Admx_WritesExactlyTheValuesRotulaReads_UnderThePolicyKey() + { + var admx = Admx(); + var policies = admx.Descendants(Gp + "policy").ToList(); + Assert.NotEmpty(policies); + Assert.All(policies, p => Assert.Equal(PolicyNames.KeyPath, (string?)p.Attribute("key"))); + var valueNames = policies.Select(p => (string?)p.Attribute("valueName")) + .Concat(policies.SelectMany(p => p.Descendants()).Select(e => (string?)e.Attribute("valueName"))) + .OfType().ToHashSet(); + Assert.Equal(PolicyNames.All.OrderBy(n => n), valueNames.OrderBy(n => n)); + } + + [Fact] + public void Admx_LanguageChoicesAreTheOnesRotulaAccepts() + { + var values = Admx().Descendants(Gp + "enum").Single(e => (string?)e.Attribute("valueName") == PolicyNames.Language) + .Descendants(Gp + "string").Select(s => s.Value).ToList(); + Assert.Equal(new[] { "auto", "en", "es" }, values); + Assert.All(values, v => Assert.Equal(v, RotulaPolicy.Read(new FakePolicySource().Set(PolicyNames.Language, v)).Language)); + } + + [Theory] + [InlineData("en-US")] + [InlineData("es-ES")] + public void Adml_DefinesEveryStringAndPresentationTheAdmxUses(string culture) + { + var admx = Admx().ToString(); + var adml = Adml(culture); + var strings = adml.Descendants(Gp + "string").Select(s => (string)s.Attribute("id")!).ToHashSet(); + var presentations = adml.Descendants(Gp + "presentation").Select(s => (string)s.Attribute("id")!).ToHashSet(); + foreach (System.Text.RegularExpressions.Match m in System.Text.RegularExpressions.Regex.Matches(admx, @"\$\(string\.([A-Za-z0-9_]+)\)")) + Assert.Contains(m.Groups[1].Value, strings); + foreach (System.Text.RegularExpressions.Match m in System.Text.RegularExpressions.Regex.Matches(admx, @"\$\(presentation\.([A-Za-z0-9_]+)\)")) + Assert.Contains(m.Groups[1].Value, presentations); + Assert.All(adml.Descendants(Gp + "string"), s => Assert.False(string.IsNullOrWhiteSpace(s.Value))); + } + + [Fact] + public void Adml_BothLanguagesHaveTheSameIds() + { + static string[] Ids(XDocument d) => d.Descendants(Gp + "string").Select(s => (string)s.Attribute("id")!).OrderBy(x => x).ToArray(); + Assert.Equal(Ids(Adml("en-US")), Ids(Adml("es-ES"))); + } +} From e96eb98fd775d2c2f78e69cd74348b0da9a3c578 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 13:21:28 +0000 Subject: [PATCH 2/5] feat(app): managed settings in the UI and an opt-in update check Settings fixed by a policy are disabled with a "Managed by your organization" note. Help > Check for updates once a day is off by default; when the user or a policy switches it on, Rotula sends at most one anonymous GET of the GitHub latest-release API a day and shows a non-modal notice linking to the release page. Nothing is downloaded or installed. Version compare, schedule, parsing and the checker are UI-free with tests; HTTP is behind IReleaseFeed. --- src/Rotula.App/App.xaml.cs | 25 +- src/Rotula.App/MainWindow.xaml | 30 +- src/Rotula.App/Resources/Strings.en.resx | 25 ++ src/Rotula.App/Resources/Strings.es.resx | 25 ++ src/Rotula.App/Services/ServiceInterfaces.cs | 7 + src/Rotula.App/Services/UpdateCheck.cs | 291 +++++++++++++ src/Rotula.App/Services/WpfServices.cs | 20 + src/Rotula.App/ViewModels/MainViewModel.cs | 86 +++- .../ViewModels/PrinterSettingsViewModel.cs | 12 +- .../ViewModels/UpdateNoticeViewModel.cs | 56 +++ tests/Rotula.Tests/AppLocalizationTests.cs | 2 +- tests/Rotula.Tests/AppUpdateCheckTests.cs | 404 ++++++++++++++++++ 12 files changed, 969 insertions(+), 14 deletions(-) create mode 100644 src/Rotula.App/Services/UpdateCheck.cs create mode 100644 src/Rotula.App/ViewModels/UpdateNoticeViewModel.cs create mode 100644 tests/Rotula.Tests/AppUpdateCheckTests.cs 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/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/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-