From 0b5017739caa9c93dfec66a848f09976a8c2d6ab Mon Sep 17 00:00:00 2001 From: livio Date: Mon, 28 Sep 2026 19:41:57 +0200 Subject: [PATCH] Prepare Pro 1.0.1 imports and license tooling --- Components/Pages/Home.razor | 131 +++++++++++++++++- LMEMappeCatastaliApp.csproj | 4 +- MauiProgram.cs | 1 + Platforms/Android/MainActivity.cs | 75 +++++++++- README.md | 9 ++ Services/AppUpdateService.cs | 48 ++++++- Services/IncomingFileService.cs | 22 +++ scripts/build-android-test.ps1 | 16 ++- tools/LmeLicenseManager/LicenseRepository.cs | 32 ++++- tools/LmeLicenseManager/MainForm.cs | 15 +- tools/LmeLicenseManager/Program.cs | 28 +++- .../manual-license-public.pem | 11 ++ wwwroot/js/lme-map.js | 93 ++++++++++--- 13 files changed, 437 insertions(+), 48 deletions(-) create mode 100644 Services/IncomingFileService.cs create mode 100644 tools/LmeLicenseManager/manual-license-public.pem diff --git a/Components/Pages/Home.razor b/Components/Pages/Home.razor index e2d67a6..6ebd7aa 100644 --- a/Components/Pages/Home.razor +++ b/Components/Pages/Home.razor @@ -10,6 +10,7 @@ @inject MapExportService MapExportService @inject CloudSyncService CloudSyncService @inject AppUpdateService AppUpdateService +@inject IncomingFileService IncomingFiles @inject AppLocalizationService L10n @inject EntitlementService Entitlements @implements IAsyncDisposable @@ -741,6 +742,7 @@ private bool NavigationMenuOpen { get; set; } private bool HasUnsavedChanges { get; set; } private DotNetObjectReference? MapCallbackReference { get; set; } + private bool MapReady { get; set; } private bool IncludeCadastralParcels { get; set; } = true; private bool IncludeKmlSheetLayer { get; set; } private bool ShowImportedSheetLayer { get; set; } @@ -830,6 +832,7 @@ protected override async Task OnInitializedAsync() { + IncomingFiles.FileAvailable += OnIncomingFileAvailable; L10n.Load(); L10n.ApplyCulture(); await Entitlements.LoadAsync(); @@ -878,6 +881,7 @@ { MapCallbackReference = DotNetObjectReference.Create(this); await JS.InvokeVoidAsync("lmeMap.init", MapCallbackReference); + MapReady = true; await ApplyLocalizationToMapAsync(); await ApplyEntitlementsToMapAsync(); await ApplyFlagPreferencesToMapAsync(); @@ -886,6 +890,7 @@ await MigrateLegacyProjectAsync(); var offlineCount = await JS.InvokeAsync("lmeMap.getOfflineTileStats"); OfflineStatus = offlineCount == 0 ? "Nessuna tessera salvata." : $"{offlineCount} tessere disponibili offline."; + await ProcessIncomingFilesAsync(); await InvokeAsync(StateHasChanged); } } @@ -1582,7 +1587,11 @@ private async Task RestoreBackup() { if (!CanUse(AppFeatures.ProjectArchive)) { await ShowProRequiredAsync(); return; } - var file = await FilePicker.Default.PickAsync(new PickOptions { PickerTitle = "Scegli backup LME" }); + var file = await FilePicker.Default.PickAsync(new PickOptions + { + PickerTitle = "Scegli backup LME (.lmebackup.zip)", + FileTypes = BackupPickerFileTypes + }); if (file is null) return; var path = await CopyPickedFileToCacheAsync(file); var count = await ProjectStorageService.RestoreBackupAsync(path); @@ -1593,7 +1602,11 @@ private async Task ImportProject() { if (!CanUse(AppFeatures.ProjectArchive)) { await ShowProRequiredAsync(); return; } - var file = await FilePicker.Default.PickAsync(new PickOptions { PickerTitle = "Scegli progetto LME" }); + var file = await FilePicker.Default.PickAsync(new PickOptions + { + PickerTitle = "Scegli progetto LME (.lmeproject.json)", + FileTypes = ProjectPickerFileTypes + }); if (file is null) return; var path = await CopyPickedFileToCacheAsync(file); var imported = await ProjectStorageService.ImportProjectAsync(path); @@ -1612,6 +1625,117 @@ return path; } + private void OnIncomingFileAvailable() + { + if (MapReady) + { + _ = InvokeAsync(ProcessIncomingFilesAsync); + } + } + + private async Task ProcessIncomingFilesAsync() + { + while (IncomingFiles.TryDequeue(out var incoming) && incoming is not null) + { + try + { + var lowerName = incoming.FileName.ToLowerInvariant(); + if (lowerName.EndsWith(".kml", StringComparison.Ordinal) + || lowerName.EndsWith(".kmz", StringComparison.Ordinal) + || lowerName.EndsWith(".geojson", StringComparison.Ordinal) + || await IsGeoJsonAsync(incoming.Path)) + { + var content = Convert.ToBase64String(await File.ReadAllBytesAsync(incoming.Path)); + await JS.InvokeAsync("lmeMap.importExternalGisFile", incoming.FileName, content, incoming.ContentType); + continue; + } + + if (lowerName.EndsWith(".zip", StringComparison.Ordinal)) + { + if (!CanUse(AppFeatures.ProjectArchive)) + { + await ShowProRequiredAsync(); + continue; + } + + var count = await ProjectStorageService.RestoreBackupAsync(incoming.Path); + await RefreshProjectsAsync(); + await JS.InvokeVoidAsync("lmeMap.showInfo", $"Backup aperto: {count} progetti ripristinati."); + continue; + } + + if (lowerName.EndsWith(".json", StringComparison.Ordinal)) + { + if (!CanUse(AppFeatures.ProjectArchive)) + { + await ShowProRequiredAsync(); + continue; + } + + var imported = await ProjectStorageService.ImportProjectAsync(incoming.Path); + await RefreshProjectsAsync(); + SelectedProjectId = imported.Id; + await RefreshProjectVersionsAsync(true); + await JS.InvokeVoidAsync("lmeMap.showInfo", $"Progetto aperto: {imported.Name}."); + continue; + } + + await JS.InvokeVoidAsync("lmeMap.showInfo", $"Formato non riconosciuto: {incoming.FileName}."); + } + catch (Exception error) + { + await JS.InvokeVoidAsync("lmeMap.showInfo", $"Impossibile aprire {incoming.FileName}: {error.GetBaseException().Message}"); + } + finally + { + try { File.Delete(incoming.Path); } catch { } + } + } + } + + private static async Task IsGeoJsonAsync(string path) + { + if (!Path.GetExtension(path).Equals(".json", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + try + { + await using var stream = File.OpenRead(path); + using var document = await System.Text.Json.JsonDocument.ParseAsync(stream); + if (!document.RootElement.TryGetProperty("type", out var typeProperty)) + { + return false; + } + + var type = typeProperty.GetString(); + return type is "Feature" or "FeatureCollection" or "Point" or "MultiPoint" + or "LineString" or "MultiLineString" or "Polygon" or "MultiPolygon" + or "GeometryCollection"; + } + catch + { + return false; + } + } + + private static readonly FilePickerFileType ProjectPickerFileTypes = new(new Dictionary> + { + [DevicePlatform.Android] = ["application/json", "text/json", "text/plain", "application/octet-stream"], + [DevicePlatform.WinUI] = [".json"], + [DevicePlatform.iOS] = ["public.json", "public.data"], + [DevicePlatform.macOS] = ["public.json", "public.data"] + }); + + private static readonly FilePickerFileType BackupPickerFileTypes = new(new Dictionary> + { + [DevicePlatform.Android] = ["application/zip", "application/x-zip-compressed", "application/octet-stream"], + [DevicePlatform.WinUI] = [".zip"], + [DevicePlatform.iOS] = ["public.zip-archive", "public.data"], + [DevicePlatform.macOS] = ["public.zip-archive", "public.data"] + }); + private async Task LoadProjectVersion() { if (SelectedProject is null || string.IsNullOrWhiteSpace(SelectedVersionId)) return; @@ -1928,7 +2052,7 @@ UpdateStatus = $"Installata {AppUpdateService.CurrentVersion}. Disponibile {update.Version}."; UpdateDownloadUrl = update.DownloadUrl; } - else UpdateStatus = $"Versione {AppUpdateService.CurrentVersion} aggiornata. Ultima release Gitea: {update.Version}."; + else UpdateStatus = $"Versione {AppUpdateService.CurrentVersion} aggiornata. Ultima release Pro su Gitea: {update.Version}."; } catch (Exception error) { UpdateStatus = $"Controllo non riuscito: {error.GetBaseException().Message}"; } finally { UpdateBusy = false; } @@ -2349,6 +2473,7 @@ public ValueTask DisposeAsync() { + IncomingFiles.FileAvailable -= OnIncomingFileAvailable; CancelCatalogWork(); LocationService.StopTracking(); GpsLookupCancellation?.Cancel(); diff --git a/LMEMappeCatastaliApp.csproj b/LMEMappeCatastaliApp.csproj index 84693c4..f1ae7a8 100644 --- a/LMEMappeCatastaliApp.csproj +++ b/LMEMappeCatastaliApp.csproj @@ -28,8 +28,8 @@ it.liviomerola.lmemappecatastali.pro - 1.0.0 - 100 + 1.0.1 + 101 $(ApplicationDisplayVersion) diff --git a/MauiProgram.cs b/MauiProgram.cs index cccebee..27cdd45 100644 --- a/MauiProgram.cs +++ b/MauiProgram.cs @@ -26,6 +26,7 @@ public static class MauiProgram builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); + builder.Services.AddSingleton(IncomingFileService.Shared); builder.Services.AddSingleton(); builder.Services.AddSingleton(); #if ANDROID diff --git a/Platforms/Android/MainActivity.cs b/Platforms/Android/MainActivity.cs index f42a71f..d8ce88a 100644 --- a/Platforms/Android/MainActivity.cs +++ b/Platforms/Android/MainActivity.cs @@ -1,10 +1,83 @@ using Android.App; +using Android.Content; using Android.Content.PM; +using Android.Database; using Android.OS; +using Android.Provider; +using LMEMappeCatastaliApp.Services; namespace LMEMappeCatastaliApp; -[Activity(Theme = "@style/Maui.SplashTheme", MainLauncher = true, ConfigurationChanges = ConfigChanges.ScreenSize | ConfigChanges.Orientation | ConfigChanges.UiMode | ConfigChanges.ScreenLayout | ConfigChanges.SmallestScreenSize | ConfigChanges.Density)] +[Activity(Theme = "@style/Maui.SplashTheme", MainLauncher = true, LaunchMode = LaunchMode.SingleTop, ConfigurationChanges = ConfigChanges.ScreenSize | ConfigChanges.Orientation | ConfigChanges.UiMode | ConfigChanges.ScreenLayout | ConfigChanges.SmallestScreenSize | ConfigChanges.Density)] +[IntentFilter([Intent.ActionView], Categories = [Intent.CategoryDefault, Intent.CategoryBrowsable], DataMimeType = "application/json")] +[IntentFilter([Intent.ActionView], Categories = [Intent.CategoryDefault, Intent.CategoryBrowsable], DataMimeType = "application/geo+json")] +[IntentFilter([Intent.ActionView], Categories = [Intent.CategoryDefault, Intent.CategoryBrowsable], DataMimeType = "application/geojson")] +[IntentFilter([Intent.ActionView], Categories = [Intent.CategoryDefault, Intent.CategoryBrowsable], DataMimeType = "application/vnd.google-earth.kml+xml")] +[IntentFilter([Intent.ActionView], Categories = [Intent.CategoryDefault, Intent.CategoryBrowsable], DataMimeType = "application/vnd.google-earth.kmz")] +[IntentFilter([Intent.ActionView], Categories = [Intent.CategoryDefault, Intent.CategoryBrowsable], DataMimeType = "application/xml")] +[IntentFilter([Intent.ActionView], Categories = [Intent.CategoryDefault, Intent.CategoryBrowsable], DataMimeType = "text/xml")] +[IntentFilter([Intent.ActionView], Categories = [Intent.CategoryDefault, Intent.CategoryBrowsable], DataMimeType = "application/zip")] +[IntentFilter([Intent.ActionView], Categories = [Intent.CategoryDefault, Intent.CategoryBrowsable], DataMimeType = "application/x-zip-compressed")] +[IntentFilter([Intent.ActionView], Categories = [Intent.CategoryDefault, Intent.CategoryBrowsable], DataMimeType = "application/octet-stream")] public class MainActivity : MauiAppCompatActivity { + protected override void OnCreate(Bundle? savedInstanceState) + { + base.OnCreate(savedInstanceState); + QueueViewedFile(Intent); + } + + protected override void OnNewIntent(Intent? intent) + { + base.OnNewIntent(intent); + Intent = intent; + QueueViewedFile(intent); + } + + private void QueueViewedFile(Intent? intent) + { + if (intent?.Action == Intent.ActionView && intent.Data is not null) + { + _ = CopyViewedFileAsync(intent); + } + } + + private async Task CopyViewedFileAsync(Intent intent) + { + try + { + var uri = intent.Data!; + var fileName = ReadDisplayName(uri) ?? $"file-{Guid.NewGuid():N}"; + fileName = string.Concat(fileName.Select(character => Path.GetInvalidFileNameChars().Contains(character) ? '_' : character)); + var directory = Path.Combine(FileSystem.CacheDirectory, "Incoming"); + Directory.CreateDirectory(directory); + var path = Path.Combine(directory, $"{Guid.NewGuid():N}-{fileName}"); + await using var input = ContentResolver!.OpenInputStream(uri) + ?? throw new IOException("Impossibile leggere il file selezionato."); + await using var output = File.Create(path); + await input.CopyToAsync(output); + IncomingFileService.Shared.Enqueue(new IncomingFile(path, fileName, intent.Type)); + } + catch (Exception error) + { + System.Diagnostics.Debug.WriteLine($"Apertura file LME non riuscita: {error}"); + } + } + + private string? ReadDisplayName(Android.Net.Uri uri) + { + if (uri.Scheme == ContentResolver.SchemeFile) + { + return Path.GetFileName(uri.Path); + } + + using ICursor? cursor = ContentResolver?.Query(uri, [IOpenableColumns.DisplayName], null, null, null); + if (cursor is null || !cursor.MoveToFirst()) + { + return null; + } + + var index = cursor.GetColumnIndex(IOpenableColumns.DisplayName); + return index >= 0 ? cursor.GetString(index) : null; + } } diff --git a/README.md b/README.md index 53a8d25..e20bf10 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,15 @@ App .NET MAUI Blazor Hybrid per Android, iOS, Mac Catalyst e Windows, pensata per consultare mappe catastali e lavorare sul campo con GPS. +## Versione Pro 1.0.1 test + +- Il controllo aggiornamenti Pro ignora le release e gli APK della versione Base e segue esclusivamente il canale Pro. +- KML e KMZ generici conservano punti, linee e poligoni; il filtro particelle viene applicato soltanto ai file riconosciuti come catastali. +- Android propone LME tra le app disponibili per aprire KML, KMZ, GeoJSON, progetti JSON e backup ZIP. +- I selettori file Android mostrano correttamente progetti condivisi e backup con estensione composta. +- Il gestore licenze mantiene una chiave locale incompatibile come backup e consente il ripristino della chiave corretta senza arrestarsi. +- I comandi di backup e ripristino chiave restano visibili anche su finestre strette. + ## Versione 0.4.7 ## Preparazione versione Pro Play Store diff --git a/Services/AppUpdateService.cs b/Services/AppUpdateService.cs index 66cae9c..c1d954a 100644 --- a/Services/AppUpdateService.cs +++ b/Services/AppUpdateService.cs @@ -1,12 +1,16 @@ using System.Net.Http.Json; using System.Reflection; +using System.Text.RegularExpressions; using System.Text.Json.Serialization; namespace LMEMappeCatastaliApp.Services; public sealed class AppUpdateService { - private const string LatestReleaseUrl = "https://gitea.lmcloud.it/api/v1/repos/livio/LMEMappeCatastaliApp/releases/latest"; + private const string ReleasesUrl = "https://gitea.lmcloud.it/api/v1/repos/livio/LMEMappeCatastaliProApp/releases?limit=50"; + private static readonly Regex ProTagPattern = new( + @"^v?(?\d+\.\d+\.\d+)-pro(?:[-.].*)?$", + RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); private readonly HttpClient _httpClient = new() { Timeout = TimeSpan.FromSeconds(20) }; public static string CurrentVersion => @@ -17,21 +21,53 @@ public sealed class AppUpdateService public async Task CheckAsync(CancellationToken cancellationToken = default) { - var release = await _httpClient.GetFromJsonAsync(LatestReleaseUrl, cancellationToken) + var releases = await _httpClient.GetFromJsonAsync(ReleasesUrl, cancellationToken) ?? throw new InvalidDataException("Risposta aggiornamenti non valida."); - var latestText = (release.TagName ?? string.Empty).TrimStart('v', 'V'); + GiteaRelease? selectedRelease = null; + GiteaAsset? selectedApk = null; + Version? selectedVersion = null; + foreach (var release in releases.Where(item => !item.Draft)) + { + var match = ProTagPattern.Match(release.TagName ?? string.Empty); + var apk = release.Assets?.FirstOrDefault(item => IsProApk(item.Name)); + if (!match.Success || apk is null || !Version.TryParse(match.Groups["version"].Value, out var version)) + { + continue; + } + + if (selectedVersion is null || version > selectedVersion) + { + selectedRelease = release; + selectedApk = apk; + selectedVersion = version; + } + } + + if (selectedRelease is null || selectedApk is null || selectedVersion is null) + { + throw new InvalidDataException("Nessuna release compatibile con LME Mappe Catastali Pro trovata."); + } + _ = Version.TryParse(CurrentVersion, out var current); - _ = Version.TryParse(latestText, out var latest); - var apk = release.Assets?.FirstOrDefault(item => item.Name?.EndsWith(".apk", StringComparison.OrdinalIgnoreCase) == true); - return new(latest is not null && current is not null && latest > current, latestText, release.HtmlUrl ?? string.Empty, apk?.BrowserDownloadUrl ?? release.HtmlUrl ?? string.Empty, release.Body ?? string.Empty); + return new( + current is not null && selectedVersion > current, + selectedVersion.ToString(3), + selectedRelease.HtmlUrl ?? string.Empty, + selectedApk.BrowserDownloadUrl ?? selectedRelease.HtmlUrl ?? string.Empty, + selectedRelease.Body ?? string.Empty); } + private static bool IsProApk(string? name) => + name?.StartsWith("LME-Mappe-Catastali-Pro-", StringComparison.OrdinalIgnoreCase) == true + && name.EndsWith(".apk", StringComparison.OrdinalIgnoreCase); + private sealed class GiteaRelease { [JsonPropertyName("tag_name")] public string? TagName { get; set; } [JsonPropertyName("html_url")] public string? HtmlUrl { get; set; } [JsonPropertyName("assets")] public GiteaAsset[]? Assets { get; set; } [JsonPropertyName("body")] public string? Body { get; set; } + [JsonPropertyName("draft")] public bool Draft { get; set; } } private sealed class GiteaAsset diff --git a/Services/IncomingFileService.cs b/Services/IncomingFileService.cs new file mode 100644 index 0000000..08f6d97 --- /dev/null +++ b/Services/IncomingFileService.cs @@ -0,0 +1,22 @@ +using System.Collections.Concurrent; + +namespace LMEMappeCatastaliApp.Services; + +public sealed class IncomingFileService +{ + private readonly ConcurrentQueue _files = new(); + + public static IncomingFileService Shared { get; } = new(); + + public event Action? FileAvailable; + + public void Enqueue(IncomingFile file) + { + _files.Enqueue(file); + MainThread.BeginInvokeOnMainThread(() => FileAvailable?.Invoke()); + } + + public bool TryDequeue(out IncomingFile? file) => _files.TryDequeue(out file); +} + +public sealed record IncomingFile(string Path, string FileName, string? ContentType); diff --git a/scripts/build-android-test.ps1 b/scripts/build-android-test.ps1 index cc6fb4b..97b47ce 100644 --- a/scripts/build-android-test.ps1 +++ b/scripts/build-android-test.ps1 @@ -11,6 +11,12 @@ $signingDirectory = Join-Path $env:LOCALAPPDATA "LME\Signing" $keyStore = Join-Path $signingDirectory "lme-pro-test.keystore" $passwordFile = Join-Path $signingDirectory "lme-pro-test-password.dpapi" $outputDirectory = Join-Path $projectRoot "artifacts\android-test" +$projectPath = Join-Path $projectRoot "LMEMappeCatastaliApp.csproj" +[xml]$projectXml = Get-Content -Raw -LiteralPath $projectPath +$appVersion = ($projectXml.Project.PropertyGroup | Where-Object ApplicationDisplayVersion | Select-Object -First 1).ApplicationDisplayVersion +if ([string]::IsNullOrWhiteSpace($appVersion)) { + throw "ApplicationDisplayVersion non trovata nel progetto." +} foreach ($requiredPath in @($dotnet, $keytool, $androidSdk)) { if (-not (Test-Path -LiteralPath $requiredPath)) { @@ -50,7 +56,7 @@ $env:LME_ANDROID_TEST_KEY_PASS = $plainPassword $copiedPackages = [System.Collections.Generic.List[System.IO.FileInfo]]::new() try { foreach ($format in @("apk", "aab")) { - & $dotnet publish (Join-Path $projectRoot "LMEMappeCatastaliApp.csproj") ` + & $dotnet publish $projectPath ` -f net10.0-android ` -c $Configuration ` -p:AndroidSdkDirectory=$androidSdk ` @@ -72,7 +78,7 @@ try { throw "Nessun pacchetto $format firmato prodotto in $publishDirectory" } - $destination = Join-Path $outputDirectory "LME-Mappe-Catastali-Pro-1.0.0-test.$format" + $destination = Join-Path $outputDirectory "LME-Mappe-Catastali-Pro-$appVersion-test.$format" Copy-Item -LiteralPath $package.FullName -Destination $destination -Force $copiedPackages.Add((Get-Item -LiteralPath $destination)) } @@ -89,6 +95,12 @@ $filesToHash = @($copiedPackages) if (Test-Path -LiteralPath $licenseManagerArchive) { $filesToHash += Get-Item -LiteralPath $licenseManagerArchive } +$publicKeySource = Join-Path $projectRoot "tools\LmeLicenseManager\manual-license-public.pem" +if (Test-Path -LiteralPath $publicKeySource) { + $publicKeyDestination = Join-Path $outputDirectory "manual-license-public.pem" + Copy-Item -LiteralPath $publicKeySource -Destination $publicKeyDestination -Force + $filesToHash += Get-Item -LiteralPath $publicKeyDestination +} $hashLines = $filesToHash | ForEach-Object { $hash = Get-FileHash -Algorithm SHA256 -LiteralPath $_.FullName diff --git a/tools/LmeLicenseManager/LicenseRepository.cs b/tools/LmeLicenseManager/LicenseRepository.cs index 17ec1ff..a063cc4 100644 --- a/tools/LmeLicenseManager/LicenseRepository.cs +++ b/tools/LmeLicenseManager/LicenseRepository.cs @@ -240,14 +240,25 @@ internal sealed class LicenseRepository { if (File.Exists(PrivateKeyPath)) { - using var existing = LoadPrivateKey(); - if (!_allowKeyGeneration && !IsExpectedSigningKey(existing)) + try { - throw new CryptographicException("La chiave locale non corrisponde alla chiave pubblica incorporata in LME Pro."); - } + using var existing = LoadPrivateKey(); + if (!_allowKeyGeneration && !IsExpectedSigningKey(existing)) + { + PreserveIncompatiblePrivateKey(); + File.WriteAllText(PublicKeyPath, ExpectedPublicKeyPem, Encoding.ASCII); + return; + } - File.WriteAllText(PublicKeyPath, existing.ExportSubjectPublicKeyInfoPem(), Encoding.ASCII); - return; + File.WriteAllText(PublicKeyPath, existing.ExportSubjectPublicKeyInfoPem(), Encoding.ASCII); + return; + } + catch (CryptographicException) when (!_allowKeyGeneration) + { + PreserveIncompatiblePrivateKey(); + File.WriteAllText(PublicKeyPath, ExpectedPublicKeyPem, Encoding.ASCII); + return; + } } if (!_allowKeyGeneration) @@ -265,6 +276,15 @@ internal sealed class LicenseRepository File.SetAttributes(PrivateKeyPath, FileAttributes.Hidden); } + private void PreserveIncompatiblePrivateKey() + { + File.SetAttributes(PrivateKeyPath, FileAttributes.Normal); + var preservedPath = Path.Combine( + _directory, + $"manual-license-private-incompatible-{DateTime.UtcNow:yyyyMMddHHmmss}.dpapi"); + File.Move(PrivateKeyPath, preservedPath); + } + private static bool IsExpectedSigningKey(RSA rsa) { using var expected = RSA.Create(); diff --git a/tools/LmeLicenseManager/MainForm.cs b/tools/LmeLicenseManager/MainForm.cs index da6418d..66841e0 100644 --- a/tools/LmeLicenseManager/MainForm.cs +++ b/tools/LmeLicenseManager/MainForm.cs @@ -65,16 +65,23 @@ internal sealed class MainForm : Form }; panel.Controls.Add(text, 0, 0); - var commands = new FlowLayoutPanel { AutoSize = true, FlowDirection = FlowDirection.LeftToRight, Margin = new Padding(8, 0, 0, 12) }; + var commands = new TableLayoutPanel + { + AutoSize = true, + AutoSizeMode = AutoSizeMode.GrowAndShrink, + ColumnCount = 1, + RowCount = 3, + Margin = new Padding(8, 0, 0, 12) + }; var openFolder = new Button { Text = "Apri archivio", AutoSize = true }; openFolder.Click += (_, _) => Process.Start(new ProcessStartInfo("explorer.exe", _repository.StoragePath) { UseShellExecute = true }); var exportKey = new Button { Text = "Backup chiave", AutoSize = true }; exportKey.Click += (_, _) => ExportSigningKey(); var importKey = new Button { Text = "Ripristina chiave", AutoSize = true }; importKey.Click += (_, _) => ImportSigningKey(); - commands.Controls.Add(openFolder); - commands.Controls.Add(exportKey); - commands.Controls.Add(importKey); + commands.Controls.Add(openFolder, 0, 0); + commands.Controls.Add(exportKey, 0, 1); + commands.Controls.Add(importKey, 0, 2); panel.Controls.Add(commands, 1, 0); return panel; } diff --git a/tools/LmeLicenseManager/Program.cs b/tools/LmeLicenseManager/Program.cs index 8bb9e2d..782acae 100644 --- a/tools/LmeLicenseManager/Program.cs +++ b/tools/LmeLicenseManager/Program.cs @@ -5,6 +5,21 @@ internal static class Program [STAThread] private static void Main(string[] args) { + if (args.Length == 2 && args[0].Equals("--export-key", StringComparison.OrdinalIgnoreCase)) + { + var password = Environment.GetEnvironmentVariable("LME_LICENSE_BACKUP_PASSWORD"); + if (string.IsNullOrWhiteSpace(password)) + { + throw new InvalidOperationException("Imposta LME_LICENSE_BACKUP_PASSWORD per esportare la chiave."); + } + + var exportRepository = new LicenseRepository(); + exportRepository.Initialize(); + exportRepository.ExportSigningKeyBackup(Path.GetFullPath(args[1]), password); + Console.WriteLine($"Backup chiave creato: {Path.GetFullPath(args[1])}"); + return; + } + if (args.Contains("--self-test", StringComparer.OrdinalIgnoreCase)) { RunSelfTest(); @@ -26,6 +41,7 @@ internal static class Program { var testDirectory = Path.Combine(Path.GetTempPath(), $"LME-License-SelfTest-{Guid.NewGuid():N}"); var importedDirectory = Path.Combine(Path.GetTempPath(), $"LME-License-SelfTest-{Guid.NewGuid():N}"); + var incompatibleDirectory = Path.Combine(Path.GetTempPath(), $"LME-License-SelfTest-{Guid.NewGuid():N}"); const string backupPassword = "LME-Test-Backup-Password-2026"; try { @@ -60,11 +76,21 @@ internal static class Program throw new InvalidOperationException("La chiave importata non produce licenze compatibili."); } + var incompatibleRepository = new LicenseRepository(incompatibleDirectory, allowKeyGeneration: true); + incompatibleRepository.Initialize(); + var strictRepository = new LicenseRepository(incompatibleDirectory); + strictRepository.Initialize(); + if (File.Exists(Path.Combine(incompatibleDirectory, "manual-license-private.dpapi")) + || Directory.GetFiles(incompatibleDirectory, "manual-license-private-incompatible-*.dpapi").Length != 1) + { + throw new InvalidOperationException("La chiave incompatibile non e stata conservata correttamente."); + } + Console.WriteLine("Self-test licenze e portabilita chiave completato."); } finally { - foreach (var directory in new[] { testDirectory, importedDirectory }) + foreach (var directory in new[] { testDirectory, importedDirectory, incompatibleDirectory }) { var fullPath = Path.GetFullPath(directory); var tempRoot = Path.GetFullPath(Path.GetTempPath()); diff --git a/tools/LmeLicenseManager/manual-license-public.pem b/tools/LmeLicenseManager/manual-license-public.pem new file mode 100644 index 0000000..10d0533 --- /dev/null +++ b/tools/LmeLicenseManager/manual-license-public.pem @@ -0,0 +1,11 @@ +-----BEGIN PUBLIC KEY----- +MIIBojANBgkqhkiG9w0BAQEFAAOCAY8AMIIBigKCAYEAmjh4E1fpaaonATNDipsR +1iy5cNSStD7fgB7i5xJTSZV36Td1B7hQXfZB8Z7vx9iFJpbCQMePuFiDKNbTUQFf +EKEp8lBq7SHo0LvbKNOSK9RMkqpb3ova9Er14gSCgshivf1c774VXTlt9J7uAWoE +QeQMNKJUSogoMkxEKc4Xmye65lGjJVeiuqdDCJh5o42j8DfGwqOyGX4808vSeixe +3lArRmoq9SGNO05VVunF8NKJyhiRLfropUvC+eFsiJMp3VMEYVn5FUcd8ljO67jv +pnngWh6zCZxs+h0mT0YRU9F3tWHqqsG1PE6NZnspmBdNfUfb9LxKt1RshEhUWiBx +0vvdUtg20nESOvFnieIX64pcx0LqFlDMmEWDMQjZ7KEtwXVphN1mhsryUiS6Cz3b +oKTryH4FQY9iIWNNzdzXujrV5ycE49orFEtrXRPnXBlB97Hl3Dlxi19lvPwxWXQ5 +fKXrTJ2rL/WvlMpZ5YOggAmv7P7tA4B+Oi5uMiHzXEDlAgMBAAE= +-----END PUBLIC KEY----- diff --git a/wwwroot/js/lme-map.js b/wwwroot/js/lme-map.js index 53e1e4c..4cb5e14 100644 --- a/wwwroot/js/lme-map.js +++ b/wwwroot/js/lme-map.js @@ -182,6 +182,7 @@ window.lmeMap = { resize, setLocalization, setEntitlements, + importExternalGisFile, showInfo: setInfo }; @@ -779,29 +780,7 @@ function bindFileImport() { if (file.size > 100 * 1024 * 1024) throw new Error("file superiore a 100 MB; suddividilo prima dell'importazione"); setInfo(`Analisi di ${file.name} (${(file.size / 1024 / 1024).toFixed(1)} MB)...`); await new Promise(resolve => window.setTimeout(resolve, 20)); - const result = await readGisFile(file); - rememberMapChange(); - const geojson = result.geojson; - const parcelLayer = addImportedFeatures(geojson); - const sheetGeojson = result.sheetGeojson || emptyFeatureCollection(); - const sheetLayer = addImportedSheetFeatures(sheetGeojson); - applyImportedSheetVisibility(); - fitLayer(geojson.features.length ? parcelLayer : sheetLayer); - refreshImportedMeasurements(); - const featureCount = geojson.features.length; - const sheetCount = sheetGeojson.features.length; - const ignored = result.ignoredCount - ? `; esclusi ${result.ignoredCount} elementi del foglio o non particella` - : ""; - const includedSheets = sheetCount - ? `; ${sheetCount} elementi foglio ${lmeState.importedSheetsVisible ? "visibili" : "nascosti"}` - : ""; - const reprojected = result.reprojected ? "; coordinate Web Mercator convertite" : ""; - const warnings = result.validationWarnings ? `; ${result.validationWarnings} geometrie da verificare` : ""; - setInfo(`Caricato ${file.name}: ${featureCount} particelle/elementi${includedSheets}${ignored}${reprojected}${warnings}.`); - updateGpsInsideState(); - refreshLayerManager(); - finishMapChange(); + await importGisFile(file); } catch (error) { setInfo(`File non leggibile: ${error.message}`); } finally { @@ -811,6 +790,49 @@ function bindFileImport() { lmeState.fileImportBound = true; } +async function importExternalGisFile(fileName, base64Content, mimeType) { + const binary = atob(base64Content); + const bytes = new Uint8Array(binary.length); + for (let index = 0; index < binary.length; index++) { + bytes[index] = binary.charCodeAt(index); + } + const file = new File([bytes], fileName, { type: mimeType || "application/octet-stream" }); + if (file.size > 100 * 1024 * 1024) { + throw new Error("file superiore a 100 MB; suddividilo prima dell'importazione"); + } + setInfo(`Analisi di ${file.name} (${(file.size / 1024 / 1024).toFixed(1)} MB)...`); + await new Promise(resolve => window.setTimeout(resolve, 20)); + return importGisFile(file); +} + +async function importGisFile(file) { + const result = await readGisFile(file); + rememberMapChange(); + const geojson = result.geojson; + const parcelLayer = addImportedFeatures(geojson); + const sheetGeojson = result.sheetGeojson || emptyFeatureCollection(); + const sheetLayer = addImportedSheetFeatures(sheetGeojson); + applyImportedSheetVisibility(); + fitLayer(geojson.features.length ? parcelLayer : sheetLayer); + refreshImportedMeasurements(); + const featureCount = geojson.features.length; + const sheetCount = sheetGeojson.features.length; + const ignored = result.ignoredCount + ? `; esclusi ${result.ignoredCount} elementi del foglio o non particella` + : ""; + const includedSheets = sheetCount + ? `; ${sheetCount} elementi foglio ${lmeState.importedSheetsVisible ? "visibili" : "nascosti"}` + : ""; + const reprojected = result.reprojected ? "; coordinate Web Mercator convertite" : ""; + const warnings = result.validationWarnings ? `; ${result.validationWarnings} geometrie da verificare` : ""; + const message = `Caricato ${file.name}: ${featureCount} particelle/elementi${includedSheets}${ignored}${reprojected}${warnings}.`; + setInfo(message); + updateGpsInsideState(); + refreshLayerManager(); + finishMapChange(); + return message; +} + async function readGisFile(file) { const lowerName = file.name.toLowerCase(); @@ -1027,6 +1049,15 @@ function normalizeKmlFiles(files) { throw new Error("KML senza geometrie utilizzabili"); } + if (!looksLikeCadastralKml(features)) { + return { + geojson: { type: "FeatureCollection", features }, + sheetGeojson: emptyFeatureCollection(), + ignoredCount: 0, + reprojected: false + }; + } + const polygonParts = splitKmlPolygonFeatures(features); const parcels = filterKmlParcelFeatures(polygonParts); const parcelSet = new Set(parcels); @@ -1047,6 +1078,22 @@ function normalizeKmlFiles(files) { }; } +function looksLikeCadastralKml(features) { + return features.some(feature => { + const properties = feature?.properties || {}; + const name = String(properties.name || properties.Name || properties.LABEL || "").trim(); + const context = [ + name, + properties.layer, + properties.Layer, + properties.type, + properties.Tipo, + properties._lmeFolder + ].filter(Boolean).join(" ").toLowerCase(); + return /\b(particell|mappal|catast|foglio|parcel|cadastral)\w*/i.test(context); + }); +} + function kmlTextToGeoJson(text, sourceName) { const xml = new DOMParser().parseFromString(text, "text/xml"); if (xml.getElementsByTagName("parsererror").length) {