Repository navigation
Expand file tree
/
Copy pathUpdateChecker.vb
More file actions
903 lines (815 loc) · 43.2 KB
/
Copy pathUpdateChecker.vb
File metadata and controls
903 lines (815 loc) · 43.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
Imports System.IO
Imports System.IO.Compression
Imports System.Net.Http
Imports System.Net.Http.Headers
Imports System.Diagnostics
Imports System.Threading
Imports System.Threading.Tasks
Imports System.Windows
Imports System.Text.Json
Imports System.Security.Cryptography
Imports System.Security.Cryptography.X509Certificates
Imports System.Text.RegularExpressions
''' <summary>
''' Gestisce il controllo, download ed installazione automatica degli aggiornamenti tramite GitHub Releases o cartella di rete locale (OTA).
''' </summary>
Public Class UpdateChecker
Private Shared _hasChecked As Boolean = False
Private Shared ReadOnly _httpClient As New HttpClient()
Private Shared ReadOnly _downloadClient As New HttpClient() With {
.Timeout = System.Threading.Timeout.InfiniteTimeSpan
}
Private Shared ReadOnly _logLock As New Object()
Shared Sub New()
_httpClient.Timeout = TimeSpan.FromSeconds(15)
_httpClient.DefaultRequestHeaders.UserAgent.Add(New ProductInfoHeaderValue("HidaChat-App", Constants.AppVersion))
_httpClient.DefaultRequestHeaders.Accept.Add(New MediaTypeWithQualityHeaderValue("application/json"))
_downloadClient.DefaultRequestHeaders.UserAgent.Add(New ProductInfoHeaderValue("HidaChat-App", Constants.AppVersion))
End Sub
''' <summary>
''' Registra un messaggio di log su Debug e sul file portabile 'data/updates.log'.
''' </summary>
Private Shared Sub Log(message As String)
Dim logLine = $"[{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}] {message}"
Debug.WriteLine(logLine)
Try
Dim dataDir = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "data")
If Not Directory.Exists(dataDir) Then
Directory.CreateDirectory(dataDir)
End If
Dim logFile = Path.Combine(dataDir, "updates.log")
SyncLock _logLock
Dim fi As New FileInfo(logFile)
If fi.Exists AndAlso fi.Length > 1024 * 1024 Then
Dim oldLog = Path.Combine(dataDir, "updates.log.old")
If File.Exists(oldLog) Then File.Delete(oldLog)
File.Move(logFile, oldLog)
End If
File.AppendAllText(logFile, logLine & Environment.NewLine)
End SyncLock
Catch
' Fail-silent per prevenire eccezioni I/O nel logging
End Try
End Sub
''' <summary>
''' Mostra un MessageBox sincronizzandosi in modo sicuro con il Dispatcher UI.
''' </summary>
Private Shared Sub ShowMessageBox(message As String, caption As String, button As MessageBoxButton, image As MessageBoxImage)
If Application.Current IsNot Nothing AndAlso Application.Current.Dispatcher IsNot Nothing Then
If Application.Current.Dispatcher.CheckAccess() Then
MessageBox.Show(message, caption, button, image)
Else
Application.Current.Dispatcher.Invoke(Sub() MessageBox.Show(message, caption, button, image))
End If
Else
MessageBox.Show(message, caption, button, image)
End If
End Sub
''' <summary>
''' Mostra una finestra di conferma MessageBox e ne restituisce il risultato sincronizzandosi con il Dispatcher UI.
''' </summary>
Private Shared Function ShowMessageBoxConfirm(message As String, caption As String, button As MessageBoxButton, image As MessageBoxImage) As MessageBoxResult
If Application.Current IsNot Nothing AndAlso Application.Current.Dispatcher IsNot Nothing Then
If Application.Current.Dispatcher.CheckAccess() Then
Return MessageBox.Show(message, caption, button, image)
Else
Return Application.Current.Dispatcher.Invoke(Function() MessageBox.Show(message, caption, button, image))
End If
Else
Return MessageBox.Show(message, caption, button, image)
End If
End Function
''' <summary>
''' Rimuove il prefisso 'v' e gli spazi vuoti da una stringa di versione (es. "v1.2.0" -> "1.2.0").
''' </summary>
Private Shared Function CleanVersionString(verStr As String) As String
If String.IsNullOrWhiteSpace(verStr) Then Return String.Empty
Dim clean = verStr.Trim()
If clean.StartsWith("v", StringComparison.OrdinalIgnoreCase) Then
clean = clean.Substring(1)
End If
Return clean
End Function
''' <summary>
''' Confronta due identificatori o suffissi di pre-release (es. "beta2" vs "beta10", "beta.1" vs "beta.2", "rc1" vs "beta2")
''' secondo le specifiche SemVer, gestendo il parsing numerico dei singoli token.
''' </summary>
Friend Shared Function ComparePrerelease(preA As String, preB As String) As Integer
If String.Equals(preA, preB, StringComparison.OrdinalIgnoreCase) Then Return 0
If String.IsNullOrEmpty(preA) AndAlso Not String.IsNullOrEmpty(preB) Then Return 1
If Not String.IsNullOrEmpty(preA) AndAlso String.IsNullOrEmpty(preB) Then Return -1
Dim matchesA = Regex.Matches(preA, "\d+|[^\d\.\-_+]+")
Dim matchesB = Regex.Matches(preB, "\d+|[^\d\.\-_+]+")
Dim count = Math.Min(matchesA.Count, matchesB.Count)
For i As Integer = 0 To count - 1
Dim tokenA = matchesA(i).Value
Dim tokenB = matchesB(i).Value
Dim valA As Long
Dim valB As Long
Dim isNumA = Long.TryParse(tokenA, valA)
Dim isNumB = Long.TryParse(tokenB, valB)
If isNumA AndAlso isNumB Then
If valA <> valB Then
Return valA.CompareTo(valB)
End If
ElseIf isNumA AndAlso Not isNumB Then
' In SemVer un identificatore numerico ha precedenza inferiore a un identificatore alfabetico
Return -1
ElseIf Not isNumA AndAlso isNumB Then
Return 1
Else
Dim cmp = String.Compare(tokenA, tokenB, StringComparison.OrdinalIgnoreCase)
If cmp <> 0 Then
Return cmp
End If
End If
Next
' Se tutti i token comuni sono identici, la versione con più token ha precedenza maggiore (es. "beta.1" > "beta")
Return matchesA.Count.CompareTo(matchesB.Count)
End Function
''' <summary>
''' Confronta la versione remota con la versione corrente e restituisce true se la versione remota è più recente.
''' Gestisce correttamente la separazione tra parti numeriche e suffissi di pre-release (es. "0.2.4-beta" vs "0.2.3", "beta10" vs "beta2").
''' </summary>
Public Shared Function IsNewerVersion(remote As String, current As String) As Boolean
Dim cleanRemote = CleanVersionString(remote)
Dim cleanCurrent = CleanVersionString(current)
If String.IsNullOrEmpty(cleanRemote) OrElse String.IsNullOrEmpty(cleanCurrent) Then
Return False
End If
Dim remoteBase As String = cleanRemote
Dim remotePre As String = String.Empty
Dim remoteDashIdx = cleanRemote.IndexOf("-"c)
If remoteDashIdx >= 0 Then
remoteBase = cleanRemote.Substring(0, remoteDashIdx)
remotePre = cleanRemote.Substring(remoteDashIdx + 1).Trim()
End If
Dim currentBase As String = cleanCurrent
Dim currentPre As String = String.Empty
Dim currentDashIdx = cleanCurrent.IndexOf("-"c)
If currentDashIdx >= 0 Then
currentBase = cleanCurrent.Substring(0, currentDashIdx)
currentPre = cleanCurrent.Substring(currentDashIdx + 1).Trim()
End If
Dim rParts = remoteBase.Split("."c)
Dim cParts = currentBase.Split("."c)
Dim maxLen = Math.Max(rParts.Length, cParts.Length)
For i As Integer = 0 To maxLen - 1
Dim rVal As Integer = 0
Dim cVal As Integer = 0
If i < rParts.Length Then Integer.TryParse(rParts(i), rVal)
If i < cParts.Length Then Integer.TryParse(cParts(i), cVal)
If rVal > cVal Then Return True
If rVal < cVal Then Return False
Next
' Se la parte numerica base è identica, confronta i suffissi prerelease:
' 1. Se remote non ha prerelease (release stabile) e current ha prerelease (beta), remote è più recente.
If String.IsNullOrEmpty(remotePre) AndAlso Not String.IsNullOrEmpty(currentPre) Then
Return True
End If
' 2. Se remote ha prerelease (beta) e current non ha prerelease (stabile), remote è considerata antecedente alla stabile.
If Not String.IsNullOrEmpty(remotePre) AndAlso String.IsNullOrEmpty(currentPre) Then
Return False
End If
' 3. Se entrambe hanno prerelease, confronta i suffissi con semantica SemVer numerica (es. "beta10" > "beta2")
If Not String.IsNullOrEmpty(remotePre) AndAlso Not String.IsNullOrEmpty(currentPre) Then
Return ComparePrerelease(remotePre, currentPre) > 0
End If
Return False
End Function
''' <summary>
''' Controlla la presenza di aggiornamenti su GitHub Releases ed eventualmente esegue il download ed installazione.
''' </summary>
''' <param name="settings">Controller delle impostazioni utente.</param>
''' <param name="accountManager">Gestore degli account.</param>
''' <param name="force">Se true, forza il controllo ignorando le impostazioni automatiche all'avvio.</param>
Public Shared Async Function CheckForUpdatesAsync(
settings As SettingsController,
accountManager As AccountManager,
Optional force As Boolean = False
) As Task
If Not force AndAlso _hasChecked Then Return
_hasChecked = True
Dim installDir = Path.GetDirectoryName(Process.GetCurrentProcess().MainModule.FileName)
Dim loc = settings?.Localizations
If Not force AndAlso Not settings.CheckForUpdates Then
Log("Update check on launch is disabled by user.")
Return
End If
Try
' 1. Tenta la verifica tramite le API di GitHub Releases
Dim releaseInfo = Await FetchGitHubReleaseInfoAsync(settings.UseBetaChannel)
If releaseInfo IsNot Nothing Then
Dim remoteVersion = releaseInfo.Version
Dim downloadUrl = releaseInfo.DownloadUrl
Log($"GitHub Release check: Current={Constants.AppVersion}, Remote={remoteVersion}")
If IsNewerVersion(remoteVersion, Constants.AppVersion) Then
If Not String.IsNullOrEmpty(downloadUrl) Then
Await PerformUpdateFromGitHubAsync(releaseInfo, installDir, settings)
Return
Else
Log("GitHub release found but no ZIP asset attached.")
If force Then
Dim noAssetMsg = If(loc IsNot Nothing,
loc.Get("update_no_asset"),
"Nessun pacchetto di aggiornamento valido allegato alla versione remota.")
Dim noAssetTitle = If(loc IsNot Nothing, loc.Get("update_check_failed_title"), "Verifica Aggiornamenti")
ShowMessageBox(noAssetMsg, noAssetTitle, MessageBoxButton.OK, MessageBoxImage.Warning)
End If
Return
End If
ElseIf CleanVersionString(remoteVersion).Equals(CleanVersionString(Constants.AppVersion), StringComparison.OrdinalIgnoreCase) Then
If force Then
Dim msg = If(loc IsNot Nothing,
loc.Get("update_already_latest", New Dictionary(Of String, String) From {{"version", Constants.AppVersion}}),
$"Hai già la versione più recente (v{Constants.AppVersion})!")
Dim title = If(loc IsNot Nothing, loc.Get("update_already_latest_title"), "Aggiornato")
ShowMessageBox(msg, title, MessageBoxButton.OK, MessageBoxImage.Information)
End If
Return
Else
If force Then
Dim msg = If(loc IsNot Nothing,
loc.Get("update_no_new_version", New Dictionary(Of String, String) From {{"remote", remoteVersion}, {"current", Constants.AppVersion}}),
$"La versione remota (v{remoteVersion}) è precedente o uguale a quella corrente (v{Constants.AppVersion}).")
Dim title = If(loc IsNot Nothing, loc.Get("update_no_new_version_title"), "Aggiornamento")
ShowMessageBox(msg, title, MessageBoxButton.OK, MessageBoxImage.Information)
End If
Return
End If
Else
Log("GitHub release info could not be fetched or endpoint unreachable.")
If force Then
Dim failMsg = If(loc IsNot Nothing,
loc.Get("update_check_failed_network"),
"Impossibile verificare la presenza di aggiornamenti. Verifica la connessione Internet o riprova più tardi.")
Dim failTitle = If(loc IsNot Nothing, loc.Get("update_check_failed_title"), "Verifica Aggiornamenti")
ShowMessageBox(failMsg, failTitle, MessageBoxButton.OK, MessageBoxImage.Warning)
End If
End If
Catch ex As Exception
Log($"GitHub update check error: {ex.Message}")
If force Then
Dim failMsg = If(loc IsNot Nothing,
loc.Get("update_check_failed", New Dictionary(Of String, String) From {{"error", ex.Message}}),
"Errore durante la verifica degli aggiornamenti:" & vbCrLf & vbCrLf & ex.Message)
Dim failTitle = If(loc IsNot Nothing, loc.Get("update_check_failed_title"), "Verifica Aggiornamenti")
ShowMessageBox(failMsg, failTitle, MessageBoxButton.OK, MessageBoxImage.Warning)
End If
End Try
End Function
Private Class ReleaseInfo
Public Property Version As String = String.Empty
Public Property DownloadUrl As String = String.Empty
Public Property ZipFileName As String = String.Empty
Public Property Sha256Url As String = String.Empty
Public Property ExpectedSha256 As String = String.Empty
Public Property Notes As String = String.Empty
End Class
''' <summary>
''' Estrae un hash SHA-256 valido (64 caratteri esadecimali) associato al file specificato o da un pattern etichettato.
''' Non accetta stringhe esadecimali isolate prive di contesto per prevenire falsi positivi (Fail-Closed).
''' </summary>
Private Shared Function ExtractSha256FromText(text As String, Optional filename As String = Nothing) As String
If String.IsNullOrWhiteSpace(text) Then Return String.Empty
' 1. Se c'è un nome file da cercare (es. formato standard sha256sum: "<hash> <filename>" o "<hash> *<filename>")
If Not String.IsNullOrWhiteSpace(filename) Then
Dim escapedName = Regex.Escape(Path.GetFileName(filename))
Dim matchFile = Regex.Match(text, "([a-fA-F0-9]{64})\s+[\*]?" & escapedName & "\b", RegexOptions.IgnoreCase)
If matchFile.Success Then
Return matchFile.Groups(1).Value.ToLowerInvariant()
End If
' 1b. Formato inverso o etichettato con nome file: "<filename>[:=] <hash>"
Dim matchFileRev = Regex.Match(text, escapedName & "\s*[:=\-]\s*([a-fA-F0-9]{64})\b", RegexOptions.IgnoreCase)
If matchFileRev.Success Then
Return matchFileRev.Groups(1).Value.ToLowerInvariant()
End If
End If
' 2. Cerca pattern espliciti etichettati tipo "SHA256: <hash>", "SHA-256: <hash>" o "checksum: <hash>"
Dim matchLabeled = Regex.Match(text, "(?:SHA-?256|checksum)\s*[:=]\s*([a-fA-F0-9]{64})\b", RegexOptions.IgnoreCase)
If matchLabeled.Success Then
Return matchLabeled.Groups(1).Value.ToLowerInvariant()
End If
' Rimosso il fallback arbitrario su stringhe esadecimali a 64 caratteri per prevenire falsi positivi (commit SHA, ecc.)
Return String.Empty
End Function
''' <summary>
''' Calcola l'hash crittografico SHA-256 in formato esadecimale minuscolo leggendo direttamente da uno Stream.
''' </summary>
Private Shared Async Function ComputeSha256Async(stream As Stream) As Task(Of String)
If stream Is Nothing Then Return String.Empty
Dim hashBytes = Await SHA256.HashDataAsync(stream)
Return Convert.ToHexString(hashBytes).ToLowerInvariant()
End Function
''' <summary>
''' Calcola l'hash crittografico SHA-256 in formato esadecimale minuscolo per un array di byte.
''' </summary>
Private Shared Function ComputeSha256(data As Byte()) As String
If data Is Nothing OrElse data.Length = 0 Then Return String.Empty
Dim hashBytes = SHA256.HashData(data)
Return Convert.ToHexString(hashBytes).ToLowerInvariant()
End Function
''' <summary>
''' Verifica la presenza e la validità crittografica della firma digitale Authenticode sull'eseguibile.
''' Restituisce (HasSignature, IsValid, SignerName).
''' </summary>
Private Shared Function VerifyAuthenticodeSignature(filePath As String) As (HasSignature As Boolean, IsValid As Boolean, SignerName As String)
If String.IsNullOrWhiteSpace(filePath) OrElse Not File.Exists(filePath) Then
Return (False, False, String.Empty)
End If
Try
#Disable Warning SYSLIB0057
Dim rawCert = X509Certificate.CreateFromSignedFile(filePath)
#Enable Warning SYSLIB0057
If rawCert IsNot Nothing Then
Using cert2 As New X509Certificate2(rawCert)
Dim isValid = cert2.Verify()
Return (True, isValid, cert2.Subject)
End Using
End If
Catch
End Try
Return (False, False, String.Empty)
End Function
''' <summary>
''' Estrae in modo centralizzato e sicuro i metadati di una release (versione, note, url zip e checksum sha256)
''' da un nodo JsonElement proveniente dalle API GitHub Releases.
''' </summary>
Private Shared Function ParseRelease(rel As JsonElement) As ReleaseInfo
Try
Dim prop As JsonElement = Nothing
Dim tagName As String = String.Empty
If rel.TryGetProperty("tag_name", prop) AndAlso prop.ValueKind = JsonValueKind.String Then
tagName = prop.GetString()
End If
Dim cleanVer = CleanVersionString(tagName)
Dim notes As String = String.Empty
If rel.TryGetProperty("body", prop) AndAlso prop.ValueKind = JsonValueKind.String Then
notes = prop.GetString()
End If
Dim zipUrl As String = String.Empty
Dim zipName As String = String.Empty
Dim sha256Url As String = String.Empty
If rel.TryGetProperty("assets", prop) AndAlso prop.ValueKind = JsonValueKind.Array Then
For Each asset In prop.EnumerateArray()
Dim assetProp As JsonElement = Nothing
Dim name As String = String.Empty
Dim assetUrl As String = String.Empty
If asset.TryGetProperty("name", assetProp) AndAlso assetProp.ValueKind = JsonValueKind.String Then
name = assetProp.GetString()
End If
If asset.TryGetProperty("browser_download_url", assetProp) AndAlso assetProp.ValueKind = JsonValueKind.String Then
assetUrl = assetProp.GetString()
End If
If name.EndsWith(".zip", StringComparison.OrdinalIgnoreCase) Then
zipUrl = assetUrl
zipName = name
ElseIf name.EndsWith(".sha256", StringComparison.OrdinalIgnoreCase) OrElse
name.EndsWith(".sha256sum", StringComparison.OrdinalIgnoreCase) OrElse
name.Equals("SHA256SUMS", StringComparison.OrdinalIgnoreCase) OrElse
name.Equals("SHA256SUMS.txt", StringComparison.OrdinalIgnoreCase) OrElse
name.Equals("checksums.txt", StringComparison.OrdinalIgnoreCase) Then
sha256Url = assetUrl
End If
Next
End If
Dim expectedHash = ExtractSha256FromText(notes, zipName)
Return New ReleaseInfo With {
.Version = cleanVer,
.DownloadUrl = zipUrl,
.ZipFileName = zipName,
.Sha256Url = sha256Url,
.ExpectedSha256 = expectedHash,
.Notes = notes
}
Catch ex As Exception
Log($"Error parsing release JSON: {ex.Message}")
Return Nothing
End Try
End Function
''' <summary>
''' Recupera le informazioni sull'ultima release pubblicata su GitHub interpellando le API REST.
''' </summary>
Private Shared Async Function FetchGitHubReleaseInfoAsync(useBeta As Boolean) As Task(Of ReleaseInfo)
Dim apiUrl = If(useBeta, Constants.GitHubReleasesApiUrl, Constants.GitHubLatestReleaseApiUrl)
Dim response = Await _httpClient.GetAsync(apiUrl)
If Not response.IsSuccessStatusCode Then
Log($"GitHub API response code: {response.StatusCode} ({response.ReasonPhrase})")
Return Nothing
End If
Dim jsonText = Await response.Content.ReadAsStringAsync()
Using doc As JsonDocument = JsonDocument.Parse(jsonText)
Dim root = doc.RootElement
' Se canale beta, l'endpoint restituisce un array di release:
' Esamina tutte le release disponibili e seleziona quella con la versione SemVer piu' recente
If root.ValueKind = JsonValueKind.Array Then
Dim newest As ReleaseInfo = Nothing
For Each rel In root.EnumerateArray()
Dim info = ParseRelease(rel)
If info IsNot Nothing AndAlso Not String.IsNullOrEmpty(info.DownloadUrl) Then
If newest Is Nothing OrElse IsNewerVersion(info.Version, newest.Version) Then
newest = info
End If
End If
Next
Return newest
ElseIf root.ValueKind = JsonValueKind.Object Then
Dim info = ParseRelease(root)
If info IsNot Nothing AndAlso Not String.IsNullOrEmpty(info.DownloadUrl) Then
Return info
End If
Return Nothing
Else
Log($"Unexpected GitHub API response structure: {root.ValueKind}")
Return Nothing
End If
End Using
End Function
''' <summary>
''' Scarica l'archivio ZIP da GitHub Releases tramite streaming su disco, verifica l'integrità crittografica SHA-256 (Fail-Closed)
''' e l'eventuale firma digitale Authenticode, estrae i file e riavvia l'applicazione tramite script batch protetto.
''' </summary>
Private Shared Async Function PerformUpdateFromGitHubAsync(
releaseInfo As ReleaseInfo,
installDir As String,
settings As SettingsController
) As Task
Dim latestVersion = releaseInfo.Version
Dim downloadUrl = releaseInfo.DownloadUrl
Dim loc = settings?.Localizations
' Verifica preventiva dei permessi di scrittura nella cartella corrente
Dim testFile = Path.Combine(installDir, ".update_test")
Try
File.WriteAllText(testFile, "test")
File.Delete(testFile)
Catch
Dim permMsg = If(loc IsNot Nothing,
loc.Get("update_insufficient_permissions", New Dictionary(Of String, String) From {{"version", latestVersion}}),
"Impossibile aggiornare automaticamente." & vbCrLf &
"L'applicazione non ha i permessi di scrittura nella cartella di installazione." & vbCrLf & vbCrLf &
"Sposta l'applicazione in una cartella locale con permessi di scrittura (es. Documenti, Desktop o unità USB)." & vbCrLf &
"Evita percorsi di sistema protetti come C:\Programmi." & vbCrLf & vbCrLf &
"Versione disponibile: v" & latestVersion)
Dim permTitle = If(loc IsNot Nothing, loc.Get("update_insufficient_permissions_title"), "Permessi insufficienti")
ShowMessageBox(permMsg, permTitle, MessageBoxButton.OK, MessageBoxImage.Warning)
Return
End Try
Dim promptMsg = If(loc IsNot Nothing,
loc.Get("update_available_prompt", New Dictionary(Of String, String) From {{"version", latestVersion}}),
$"È disponibile una nuova versione di HidaChat (v{latestVersion})!" & vbCrLf & vbCrLf &
"Desideri scaricare ed installare l'aggiornamento ora?")
Dim promptTitle = If(loc IsNot Nothing, loc.Get("update_available_title"), "Aggiornamento Disponibile")
Dim result = ShowMessageBoxConfirm(
promptMsg,
promptTitle,
MessageBoxButton.YesNo,
MessageBoxImage.Question
)
If result <> MessageBoxResult.Yes Then Return
If Application.Current IsNot Nothing Then
Try
If Application.Current.Dispatcher.CheckAccess() Then
System.Windows.Input.Mouse.OverrideCursor = System.Windows.Input.Cursors.Wait
Else
Application.Current.Dispatcher.Invoke(Sub()
System.Windows.Input.Mouse.OverrideCursor = System.Windows.Input.Cursors.Wait
End Sub)
End If
Catch
End Try
End If
' 1. Verifica dell'impronta crittografica SHA-256 (Fail-Closed)
Dim expectedHash = releaseInfo.ExpectedSha256
' Se non presente nelle note di rilascio, prova a scaricare il file di checksum allegato agli asset
If String.IsNullOrEmpty(expectedHash) AndAlso Not String.IsNullOrEmpty(releaseInfo.Sha256Url) Then
Try
Log($"Downloading SHA-256 checksum file from: {releaseInfo.Sha256Url}")
Dim checksumContent = Await _httpClient.GetStringAsync(releaseInfo.Sha256Url)
expectedHash = ExtractSha256FromText(checksumContent, releaseInfo.ZipFileName)
Catch ex As Exception
Log($"Could not download or parse checksum asset: {ex.Message}")
End Try
End If
' FAIL-CLOSED: Se non è presente alcun checksum SHA-256 verificabile, blocca tassativamente l'aggiornamento
If String.IsNullOrEmpty(expectedHash) Then
Log("Security fail-closed: no valid SHA-256 checksum found for this release.")
Dim missingChecksumMsg = If(loc IsNot Nothing,
loc.Get("update_missing_checksum"),
"Impossibile verificare l'integrità dell'aggiornamento." & vbCrLf & vbCrLf &
"Nessun checksum crittografico SHA-256 valido è stato fornito con questa versione su GitHub." & vbCrLf & vbCrLf &
"L'aggiornamento è stato interrotto per garantire la sicurezza del sistema.")
ShowMessageBox(
missingChecksumMsg,
If(loc IsNot Nothing, loc.Get("update_integrity_failed_title"), "Errore Integrità Aggiornamento"),
MessageBoxButton.OK,
MessageBoxImage.Error
)
Return
End If
Dim updateSessionId = DateTime.UtcNow.ToString("yyyyMMdd_HHmmss") & "_" & Guid.NewGuid().ToString("N").Substring(0, 8)
Dim tempZipPath = Path.Combine(Path.GetTempPath(), $"HidaChat_Update_{updateSessionId}.zip")
Dim tempDir = Path.Combine(Path.GetTempPath(), $"HidaChat_Update_{updateSessionId}")
Dim updateLaunched As Boolean = False
Dim dialog As UpdateProgressDialog = Nothing
' Pulizia asincrona preventiva e non bloccante di eventuali residui di aggiornamenti precedenti
Dim cleanupBgTask = Task.Run(Sub()
Try
Dim tempPath = Path.GetTempPath()
For Each dirPath In Directory.EnumerateDirectories(tempPath, "HidaChat_Update*")
Try
Directory.Delete(dirPath, True)
Catch
End Try
Next
For Each filePath In Directory.EnumerateFiles(tempPath, "HidaChat_Update*.zip")
Try
System.IO.File.Delete(filePath)
Catch
End Try
Next
Catch
End Try
End Sub)
Try
If File.Exists(tempZipPath) Then File.Delete(tempZipPath)
Catch
End Try
' Mostra la finestra di avanzamento grafica (TODO #87)
If Application.Current IsNot Nothing AndAlso Application.Current.Dispatcher IsNot Nothing Then
If Application.Current.Dispatcher.CheckAccess() Then
dialog = New UpdateProgressDialog(loc, latestVersion)
dialog.Show()
Else
Application.Current.Dispatcher.Invoke(Sub()
dialog = New UpdateProgressDialog(loc, latestVersion)
dialog.Show()
End Sub)
End If
End If
Try
' 2. Scarica lo ZIP da GitHub tramite streaming su disco con indicatore di progresso in tempo reale (TODO #87)
Log($"Downloading update zip (streaming) from: {downloadUrl}")
Using cts As New CancellationTokenSource(TimeSpan.FromMinutes(10))
Dim ct = cts.Token
Using resp = Await _downloadClient.GetAsync(downloadUrl, HttpCompletionOption.ResponseHeadersRead, ct)
resp.EnsureSuccessStatusCode()
Dim totalBytes = resp.Content.Headers.ContentLength.GetValueOrDefault(-1L)
Using src = Await resp.Content.ReadAsStreamAsync(ct),
dst = File.Create(tempZipPath)
Dim buffer(81919) As Byte
Dim totalRead As Long = 0
Dim lastUiUpdate As DateTime = DateTime.MinValue
While True
Dim bytesRead = Await src.ReadAsync(buffer, 0, buffer.Length, ct)
If bytesRead = 0 Then Exit While
Await dst.WriteAsync(buffer, 0, bytesRead, ct)
totalRead += bytesRead
Dim now = DateTime.UtcNow
If (now - lastUiUpdate).TotalMilliseconds > 80 OrElse (totalBytes > 0 AndAlso totalRead = totalBytes) Then
lastUiUpdate = now
If dialog IsNot Nothing Then
If totalBytes > 0 Then
Dim pct = (CDbl(totalRead) / CDbl(totalBytes)) * 100.0
Dim mbRead = (totalRead / (1024.0 * 1024.0)).ToString("F1")
Dim mbTotal = (totalBytes / (1024.0 * 1024.0)).ToString("F1")
Dim info = If(loc IsNot Nothing,
loc.Get("update_stage_download_progress", New Dictionary(Of String, String) From {
{"downloadedMb", mbRead},
{"totalMb", mbTotal},
{"percent", Math.Floor(pct).ToString()}
}),
$"Scaricati {mbRead} MB di {mbTotal} MB ({Math.Floor(pct)}%)")
dialog.SetProgress(pct, progressText:=info)
Else
Dim mbRead = (totalRead / (1024.0 * 1024.0)).ToString("F1")
dialog.SetProgress(-1, progressText:=$"{mbRead} MB")
End If
End If
End If
End While
End Using
End Using
End Using
Catch ex As Exception
Log($"Download failed or timed out: {ex.Message}")
If dialog IsNot Nothing Then
Try
dialog.Close()
Catch
End Try
dialog = Nothing
End If
Dim dlErrMsg = If(loc IsNot Nothing,
loc.Get("update_download_error", New Dictionary(Of String, String) From {{"error", ex.Message}}),
"Download dell'aggiornamento non riuscito o timeout superato:" & vbCrLf & vbCrLf & ex.Message)
ShowMessageBox(
dlErrMsg,
If(loc IsNot Nothing, loc.Get("update_download_error_title"), "Errore Download Aggiornamento"),
MessageBoxButton.OK,
MessageBoxImage.Error
)
Try
If File.Exists(tempZipPath) Then File.Delete(tempZipPath)
Catch
End Try
Return
End Try
Try
' 3. Verifica di integrità crittografica (SHA-256 calcolato direttamente dal file)
If dialog IsNot Nothing Then
dialog.SetIndeterminate(True)
dialog.UpdateStatus(If(loc IsNot Nothing, loc.Get("update_stage_verifying"), "Verifica integrità crittografica SHA-256 e firma..."), "")
End If
Dim computedSha256 As String
Using fs = File.OpenRead(tempZipPath)
computedSha256 = Await ComputeSha256Async(fs)
End Using
Log($"Downloaded update file SHA-256: {computedSha256}")
If Not String.Equals(computedSha256, expectedHash, StringComparison.OrdinalIgnoreCase) Then
Log($"SHA-256 mismatch! Computed: {computedSha256}, Expected: {expectedHash}")
If dialog IsNot Nothing Then
Try
dialog.Close()
Catch
End Try
dialog = Nothing
End If
Dim mismatchMsg = If(loc IsNot Nothing,
loc.Get("update_checksum_mismatch", New Dictionary(Of String, String) From {{"computed", computedSha256}, {"expected", expectedHash}}),
"Verifica di integrità fallita!" & vbCrLf & vbCrLf &
"L'impronta crittografica SHA-256 del file di aggiornamento scaricato non corrisponde a quella attesa:" & vbCrLf & vbCrLf &
$"Hash calcolato: {computedSha256}" & vbCrLf &
$"Hash atteso: {expectedHash}" & vbCrLf & vbCrLf &
"L'aggiornamento è stato interrotto per garantire la sicurezza del sistema.")
ShowMessageBox(
mismatchMsg,
If(loc IsNot Nothing, loc.Get("update_integrity_failed_title"), "Errore Integrità Aggiornamento"),
MessageBoxButton.OK,
MessageBoxImage.Error
)
Return
Else
Log($"Update integrity verified successfully with SHA-256: {computedSha256}")
End If
' 4. Estrai l'archivio temporaneo in una cartella di sessione univoca
If dialog IsNot Nothing Then
dialog.UpdateStatus(If(loc IsNot Nothing, loc.Get("update_stage_extracting"), "Estrazione dei file di aggiornamento..."), "")
End If
If Directory.Exists(tempDir) Then
Try
Directory.Delete(tempDir, True)
Catch
tempDir = Path.Combine(Path.GetTempPath(), $"HidaChat_Update_{updateSessionId}_{Guid.NewGuid():N}")
End Try
End If
Directory.CreateDirectory(tempDir)
ZipFile.ExtractToDirectory(tempZipPath, tempDir, True)
' Gestisci eventuale sottocartella singola estratta dallo ZIP
Dim sourceDir = tempDir
Dim exeInTemp = Directory.GetFiles(tempDir, "HidaChat.exe", SearchOption.AllDirectories)
If exeInTemp.Length > 0 Then
sourceDir = Path.GetDirectoryName(exeInTemp(0))
' 4b. Verifica firma digitale Authenticode se presente sull'eseguibile (Punto 57)
Dim auth = VerifyAuthenticodeSignature(exeInTemp(0))
If auth.HasSignature Then
Log($"Authenticode signature detected: Valid={auth.IsValid}, Signer={auth.SignerName}")
If Not auth.IsValid Then
Log("Security abort: Authenticode signature is present but INVALID.")
If dialog IsNot Nothing Then
Try
dialog.Close()
Catch
End Try
dialog = Nothing
End If
Dim sigErrMsg = If(loc IsNot Nothing,
loc.Get("update_signature_invalid"),
"Verifica della firma digitale fallita sull'eseguibile di aggiornamento." & vbCrLf & vbCrLf &
"L'installazione è stata interrotta per garantire la sicurezza del sistema.")
ShowMessageBox(
sigErrMsg,
If(loc IsNot Nothing, loc.Get("update_integrity_failed_title"), "Errore Integrità Aggiornamento"),
MessageBoxButton.OK,
MessageBoxImage.Error
)
Return
End If
Else
Log("Authenticode signature not present on update binary (verified via SHA-256).")
End If
End If
' 5. Handoff al processo compilato di aggiornamento (.NET / WPF) con eliminazione definitiva di script .bat / .ps1 (TODO #87)
If dialog IsNot Nothing Then
dialog.UpdateStatus(If(loc IsNot Nothing, loc.Get("update_stage_closing"), "Chiusura di HidaChat e preparazione sostituzione file..."), "")
End If
Dim updaterExe = Path.Combine(sourceDir, "HidaChat.exe")
If Not File.Exists(updaterExe) Then
Throw New FileNotFoundException("HidaChat.exe non trovato nella cartella dell'aggiornamento estratto.", updaterExe)
End If
' 6. Salva lo stato dell'applicazione e rilascia le risorse (impostazioni, tsnetd e account)
Dim mainWin As MainWindow = Nothing
If Application.Current IsNot Nothing Then
If Application.Current.Dispatcher.CheckAccess() Then
mainWin = TryCast(Application.Current.MainWindow, MainWindow)
Else
Application.Current.Dispatcher.Invoke(Sub()
mainWin = TryCast(Application.Current.MainWindow, MainWindow)
End Sub)
End If
End If
If mainWin IsNot Nothing Then
Try
If Application.Current.Dispatcher.CheckAccess() Then
Await mainWin.ForceExitForUpdateAsync()
Else
Dim op = Application.Current.Dispatcher.InvokeAsync(Async Function()
Await mainWin.ForceExitForUpdateAsync()
End Function)
Await op.Task.Unwrap()
End If
Catch ex As Exception
Throw New IOException("Update cancelled: orderly browser shutdown failed.", ex)
End Try
Else
Throw New IOException("Update cancelled: the main window is not available for orderly shutdown.")
End If
' Chiudi il dialog del processo chiamante prima del passaggio di consegne
If dialog IsNot Nothing Then
Try
dialog.Close()
Catch
End Try
dialog = Nothing
End If
' Avvia il processo updater autonomo passando i parametri necessari
Dim langCode = If(loc?.CurrentLanguage, "en")
Dim updaterArgs = $"--apply-update {Environment.ProcessId} ""{sourceDir}"" ""{installDir}"" ""{latestVersion}"" ""{langCode}"" ""{tempZipPath}"""
Log($"Starting updater process: {updaterExe} {updaterArgs}")
Process.Start(New ProcessStartInfo With {
.FileName = updaterExe,
.Arguments = updaterArgs,
.UseShellExecute = True
})
updateLaunched = True
' Il browser e i dati sono già salvati e chiusi. Termina solo questo processo per consentire la sostituzione.
Environment.Exit(0)
Catch ex As Exception
Log($"Update execution failed: {ex.Message}")
If dialog IsNot Nothing Then
Try
dialog.Close()
Catch
End Try
dialog = Nothing
End If
Dim installErrMsg = If(loc IsNot Nothing,
loc.Get("update_install_error", New Dictionary(Of String, String) From {{"error", ex.Message}}),
"Errore durante l'installazione dell'aggiornamento: " & ex.Message)
Dim installErrTitle = If(loc IsNot Nothing, loc.Get("update_install_error_title"), "Errore Aggiornamento")
ShowMessageBox(
installErrMsg,
installErrTitle,
MessageBoxButton.OK,
MessageBoxImage.Error
)
Finally
If dialog IsNot Nothing Then
Try
dialog.Close()
Catch
End Try
dialog = Nothing
End If
If Application.Current IsNot Nothing Then
Try
If Application.Current.Dispatcher.CheckAccess() Then
System.Windows.Input.Mouse.OverrideCursor = Nothing
Else
Application.Current.Dispatcher.Invoke(Sub()
System.Windows.Input.Mouse.OverrideCursor = Nothing
End Sub)
End If
Catch
End Try
End If
If Not updateLaunched Then
Try
If File.Exists(tempZipPath) Then File.Delete(tempZipPath)
Catch
End Try
Try
If Directory.Exists(tempDir) Then Directory.Delete(tempDir, True)
Catch
End Try
End If
End Try
End Function
Private Shared Sub WriteLocalVersionMarker(installDir As String, version As String)
Try
Dim markerPath = Path.Combine(installDir, ".app_version")
File.WriteAllText(markerPath, version.Trim())
Catch ex As Exception
Log($"Failed to write local version marker: {ex.Message}")
End Try
End Sub
End Class