Prepare Pro 1.0.1 imports and license tooling

This commit is contained in:
livio committed 2026-09-28 19:41:57 +02:00
1 parent 62f28c5621
commit f4b3ca6ea8
13 files changed
+437 -48

No files matched your search

+128 -3
View File
@@ -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<Home>? 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<int>("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<string>("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<bool> 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, IEnumerable<string>>
{
[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, IEnumerable<string>>
{
[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();
+2 -2
View File
@@ -28,8 +28,8 @@
<ApplicationId>it.liviomerola.lmemappecatastali.pro</ApplicationId>
<!-- Versions -->
<ApplicationDisplayVersion>1.0.0</ApplicationDisplayVersion>
<ApplicationVersion>100</ApplicationVersion>
<ApplicationDisplayVersion>1.0.1</ApplicationDisplayVersion>
<ApplicationVersion>101</ApplicationVersion>
<Version>$(ApplicationDisplayVersion)</Version>
<!-- To develop, package, and publish an app to the Microsoft Store, see: https://aka.ms/MauiTemplateUnpackaged -->
+1
View File
@@ -26,6 +26,7 @@ public static class MauiProgram
builder.Services.AddSingleton<MapExportService>();
builder.Services.AddSingleton<CloudSyncService>();
builder.Services.AddSingleton<AppUpdateService>();
builder.Services.AddSingleton(IncomingFileService.Shared);
builder.Services.AddSingleton<AppLocalizationService>();
builder.Services.AddSingleton<ManualLicenseService>();
#if ANDROID
+74 -1
View File
@@ -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;
}
}
+9
View File
@@ -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
+42 -6
View File
@@ -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?(?<version>\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<AppUpdateResult> CheckAsync(CancellationToken cancellationToken = default)
{
var release = await _httpClient.GetFromJsonAsync<GiteaRelease>(LatestReleaseUrl, cancellationToken)
var releases = await _httpClient.GetFromJsonAsync<GiteaRelease[]>(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
+22
View File
@@ -0,0 +1,22 @@
using System.Collections.Concurrent;
namespace LMEMappeCatastaliApp.Services;
public sealed class IncomingFileService
{
private readonly ConcurrentQueue<IncomingFile> _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);
+14 -2
View File
@@ -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
+26 -6
View File
@@ -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();
+11 -4
View File
@@ -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;
}
+27 -1
View File
@@ -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());
@@ -0,0 +1,11 @@
-----BEGIN PUBLIC KEY-----
MIIBojANBgkqhkiG9w0BAQEFAAOCAY8AMIIBigKCAYEAmjh4E1fpaaonATNDipsR
1iy5cNSStD7fgB7i5xJTSZV36Td1B7hQXfZB8Z7vx9iFJpbCQMePuFiDKNbTUQFf
EKEp8lBq7SHo0LvbKNOSK9RMkqpb3ova9Er14gSCgshivf1c774VXTlt9J7uAWoE
QeQMNKJUSogoMkxEKc4Xmye65lGjJVeiuqdDCJh5o42j8DfGwqOyGX4808vSeixe
3lArRmoq9SGNO05VVunF8NKJyhiRLfropUvC+eFsiJMp3VMEYVn5FUcd8ljO67jv
pnngWh6zCZxs+h0mT0YRU9F3tWHqqsG1PE6NZnspmBdNfUfb9LxKt1RshEhUWiBx
0vvdUtg20nESOvFnieIX64pcx0LqFlDMmEWDMQjZ7KEtwXVphN1mhsryUiS6Cz3b
oKTryH4FQY9iIWNNzdzXujrV5ycE49orFEtrXRPnXBlB97Hl3Dlxi19lvPwxWXQ5
fKXrTJ2rL/WvlMpZ5YOggAmv7P7tA4B+Oi5uMiHzXEDlAgMBAAE=
-----END PUBLIC KEY-----
+70 -23
View File
@@ -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) {