Repository navigation
Expand file tree
/
Copy pathWinClean.ps1
More file actions
6863 lines (6154 loc) · 339 KB
/
Copy pathWinClean.ps1
File metadata and controls
6863 lines (6154 loc) · 339 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
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
<#PSScriptInfo
.VERSION 2.22
.GUID 8f7c3b2a-1d4e-5f6a-9b8c-0d1e2f3a4b5c
.AUTHOR bivlked
.COMPANYNAME
.COPYRIGHT (c) 2026 bivlked. MIT License.
.TAGS Windows Cleanup Maintenance PowerShell Windows11 DevTools Docker WSL npm pip nuget
.LICENSEURI https://github.com/bivlked/WinClean/blob/main/LICENSE
.PROJECTURI https://github.com/bivlked/WinClean
.ICONURI https://raw.githubusercontent.com/bivlked/WinClean/main/assets/logo.svg
.EXTERNALMODULEDEPENDENCIES
.REQUIREDSCRIPTS
.EXTERNALSCRIPTDEPENDENCIES
.RELEASENOTES
v2.22: Honest completion and adaptive output - a finished Disk Cleanup is no longer reported as partial, a bad log path can no longer kill a run, every run now ends through one path, and the console output follows the window width
v2.21: Self-update targeting and honest exit codes - the update could change a file that was not the one running and still report success; a missing winget or no connectivity no longer fails the run
v2.20: Correctness and honesty round - a junction could bypass protected-path checks, four operations reported success while doing nothing, Storage Sense was unreachable so on machines where it works the slow Disk Cleanup no longer runs; where it fails, the new -SkipDiskCleanup is what removes the wait
v2.19: Contract and documentation round - -SkipCleanup now skips ALL cleanup categories, result JSON gains a tri-state PhasesSkipped, AppUpdatesCount renamed to AppUpdatesOffered (offered, not installed), full docs overhaul
v2.18: Correctness and hardening follow-up from external code review - diskpart failure detection, driver-store accounting, strict superseded-version rule, exact bootstrap host allowlist
v2.17: Silent failure hardening - operations that quietly do nothing now say so instead of reporting success
v2.16: Driver store cleanup, disk space report, kernel dump cleanup, 9 audit fixes (Delivery Optimization path, TEMP age filter, winget exit codes)
v2.15: ResultJsonPath for automated testing, one-command install/run (get.ps1, install.ps1), integration test suite
v2.14: Log persistence fix, correct npm/Firefox cache paths, localized size parsing, faster DISM/EventLogs, UI fixes
v2.13: Statistics accuracy fixes, efficiency improvements, registry cleanup
v2.12: PS 7.4+ compatibility, improved statistics (Docker/WSL/RecycleBin), ReportOnly accuracy
v2.11: Added timeouts for winget/DISM operations, fixed version display, improved reliability
v2.10: Added auto-update check at startup (checks PSGallery for new version)
v2.9: Fixed PSWindowsUpdate installation hanging (TLS 1.2, timeouts)
.PRIVATEDATA
#>
<#
.SYNOPSIS
WinClean - Ultimate Windows 11 Maintenance Script v2.22
.DESCRIPTION
Комплексный скрипт для обновления и очистки Windows 11:
- Обновление Windows (включая драйверы)
- Обновление приложений через winget
- Очистка системы, браузеров, кэшей разработчика
- Очистка Docker/WSL
- Очистка Visual Studio
- Очистка DNS кэша и истории Run
- Опциональная блокировка телеметрии Windows
- Параллельное выполнение для максимальной скорости
- Подробный цветной вывод + лог-файл
.NOTES
Author: biv
Version: 2.22
Requires: PowerShell 7.1+, Windows 11, Administrator rights
Changes in 2.22:
- A Disk Cleanup that stops doing anything but never exits is no longer waited out
for the full 15-minute timeout, nor reported as still deleting: the wait now also
ends when the process has shown no CPU or I/O activity for two minutes
- An unusable -LogPath can no longer kill the run before it starts - the log header
is written through the same fault-tolerant path as every other log line
- Every run now ends through one code path, so the result JSON, the summary and the
log handle release cannot drift apart between normal and self-update exits
- Console output adapts to the window width: the window is widened best-effort at
startup and the frames follow it, so winget's table stops wrapping into them
- install.ps1 refuses a PowerShell 7 whose version it cannot read, instead of
treating "could not check" as "good enough"
Changes in 2.21:
- The self-update could update a DIFFERENT copy than the one running and report
success - it asked whether a Gallery copy existed anywhere, not whether the
running file was that copy
- Several Gallery installations now disable the automatic update instead of
changing one at random; the running path is printed so they can be told apart
- Detection, updating and the printed advice work on a machine that has only
PSResourceGet; an AllUsers install is no longer invisible
- An update that reports success is verified against the version on disk
- A missing winget, no internet connection and a failed self-update are warnings,
not errors: the exit code no longer reports failure for a complete cleanup
- Result JSON gains AppUpdatesStatus, and Aborted gains UpdatedAndExited
Changes in 2.20:
- SECURITY: a junction whose target is a protected root could be used as a cleanup
root - the path check compared text and never resolved the link
- Storage Sense was looked up at a path where it does not exist, so every run fell
back to Disk Cleanup (15 of 18 minutes on a real workstation). Where Storage Sense
itself fails, the fallback still runs - use -SkipDiskCleanup there
- npm, event logs, privacy traces and winget source update no longer report success
when they did nothing
- Added -SkipDiskCleanup to skip only the slow Disk Cleanup step
- Result JSON gains LoggingDegraded and DiskCleanupPending
Changes in 2.19:
- -SkipCleanup now skips the ENTIRE cleanup group (system, deep, developer,
Docker/WSL, Visual Studio), matching the documented "skip all cleanup" contract.
Previously it left developer/Docker/VS cleanup running (behavior change)
- Result JSON gains a tri-state PhasesSkipped (dispatch status): a phase turned off
by a skip flag is now recorded as skipped instead of completed
- AppUpdatesCount renamed to AppUpdatesOffered - winget cannot confirm how many apps
installed, so the summary reports the offered count honestly ("Apps: N offered")
- Documentation overhaul: accurate feature list, docs/ deep-dive pages, SECURITY and
CONTRIBUTING release-gate, SHA-pinned CI actions
Changes in 2.18:
- WSL/Docker VHDX compaction now detects a diskpart failure instead of reporting
"no space saved", and a failed WSL shutdown skips compaction rather than
touching a live disk
- Driver store falls back to the repository delta whenever ANY removed package
lacks a trusted per-package size, not only when the total is zero
- Driver store "superseded" now requires a strictly newer version, never a mere
newer date at the same version
- The per-VHDX compaction failure is now counted as a warning
- The one-line install scripts validate the download host against an exact
allowlist instead of a broad *.github.com / *.githubusercontent.com suffix
- Folder size measurement distinguishes an unreadable folder from an empty one
Changes in 2.17:
- Cleanups that free nothing from a non-empty folder now say so instead of
staying silent, which was indistinguishable from "there was nothing to do"
- Kernel dump and driver package removal failures are counted and reported
- pnputil exit code is checked; a parse failure no longer looks like "nothing found"
- Driver store falls back to measuring the repository when per-package sizes
cannot be attributed, instead of reporting 0 B after a successful cleanup
- Disk Cleanup verifies that categories were armed and checks its exit code
- Browser caches are no longer reported as cleaned when nothing was freed
- The temp age filter now fails closed: an unreadable subtree is kept, not deleted
- Controlled Folder Access reports 'unknown' when the check itself fails
- Downloaded-but-not-installed updates are no longer counted as installed
Changes in 2.16:
- Added driver store cleanup: removes superseded driver packages that no device
uses and that have a newer version installed (451 MB on the author's machine)
- Added disk space report: shows large areas cleanup deliberately never touches
(MSI cache, search index, hiberfil.sys, shadow copies)
- Added kernel dump cleanup for stale LiveKernelReports files (multi-gigabyte)
- Fixed Delivery Optimization cache path - multi-gigabyte cleanups were reported as 0 B
- Temp cleanup no longer deletes files younger than a day (running installers)
- Windows Update cache cleanup now waits for the service to really stop
- Warns when Controlled Folder Access may silently block deletions
- Disk Cleanup category list reconciled with the registry; leftover StateFlags
are now swept from every handler
- winget exit codes are decoded instead of printed as a bare number
- All progress bars are closed before the summary, including foreign ones
Changes in 2.15:
- Added -ResultJsonPath: machine-readable run summary (JSON) for automated
testing, CI and VM test stands
- Added get.ps1 (one-command run from the internet) and install.ps1
(one-command install/update + elevated desktop shortcut)
- Added integration test suite (sandboxed filesystem tests) and smoke runner
with automated console box-geometry checking
Changes in 2.14:
- Fixed log file being deleted by the script's own temp cleanup (all entries
logged before Clear-TempFiles were silently lost every run)
- Fixed npm cache path for npm v7+ (LOCALAPPDATA\npm-cache; old APPDATA path kept as fallback)
- Fixed Firefox cache path (cache2/startupCache live under LOCALAPPDATA, not APPDATA)
- Fixed localized size parsing (Cyrillic units, no-break spaces) for Recycle Bin statistics
- Fixed restore points silently not created due to the 24h system frequency limit
- Fixed winget update count inflated by the "require explicit targeting" table
- Fixed Storage Sense wasting 120s + false warning when the scheduled task is disabled
- Fixed misaligned UPDATE AVAILABLE box and countdown ghost character
- DISM: component store analyzed first (/English), cleanup skipped when not needed;
DISM output redirected to keep console clean
- Event logs: only enabled non-empty Administrative/Operational logs are cleared
(much faster, no chronic partial-failure warnings)
- Delivery Optimization cache cleared via supported Delete-DeliveryOptimizationCache cmdlet
- Replaced dead connectivity probe winget.azureedge.net with cdn.winget.microsoft.com
- Removed risky cleanmgr categories (Previous Installations, Windows ESD installation files)
- Added Opera GX and uv cache cleanup; winget check hardened with --disable-interactivity
- Removed dead code (unused statusIcon/dockerInfo variables)
- Fixed Docker reclaimed-space parsing ($Matches after array -match) + exit code check
- Fixed icacls on non-English Windows (SID S-1-5-32-544 instead of localized "Administrators")
- Fixed failed Windows Update search being reported as "up to date"
- Stats no longer count locked files / undeleted Recycle Bin items as freed
- Custom -LogPath directories are created automatically
Changes in 2.13:
- Fixed Docker prune output parsing (supports "Total reclaimed space:" format)
- Fixed WarningsCount not incrementing for event log failures
- Fixed false "success" when Windows Update returns null results
- Optimized Get-FolderSize with -File flag for better performance
- Fixed temp path deduplication to avoid duplicate cleanups
- Removed redundant docker builder prune (already included in system prune)
- Fixed potential negative freed space in browser cache statistics
- Added fallback for Recycle Bin size calculation via GetDetailsOf
- Added registry cleanup for Disk Cleanup StateFlags after execution
Changes in 2.12:
- Fixed PS 7.4+ compatibility (removed deprecated -UseBasicParsing)
- Fixed DISM ReportOnly to show /ResetBase and warning
- Fixed AppUpdatesCount to only count successful updates
- Added statistics for Docker, WSL, Recycle Bin, npm cache
Changes in 2.11:
- Fixed version display bugs (banner and log showed v2.9 instead of current version)
- Added timeouts for winget/DISM operations to prevent script hangs
- Added force stop for Storage Sense when timeout exceeded
- Improved Docker detection and browser cache statistics reliability
Changes in 2.10:
- Added auto-update check: script checks PSGallery for newer version at startup
- Added Test-ScriptUpdate function: compares local version with PSGallery
- Added Invoke-ScriptUpdate function: prompts user and performs update if confirmed
- Update check runs after reboot check, before main operations
- Shows manual update instructions if script was downloaded manually (not via PSGallery)
- Respects ReportOnly mode and non-interactive environments
Changes in 2.9:
- Fixed PSWindowsUpdate installation hanging: added TLS 1.2 enforcement
- Added Test-PSGalleryConnection function: pre-checks PowerShell Gallery availability
- Added Install-ModuleWithTimeout function: 120-second timeout for Install-Module
- Added Install-PackageProviderWithTimeout function: 60-second timeout for NuGet provider
- Improved error messages with manual installation instructions
- Clear Write-Progress before module installation to prevent UI artifacts
Changes in 2.8:
- Fixed Disk Cleanup timeout: reduced from 10 minutes to 7 minutes
- Fixed Disk Cleanup: replaced -NoNewWindow with -WindowStyle Hidden (more reliable)
- Added progress logging every minute while Disk Cleanup is running
- Replaced Wait-Process with explicit HasExited loop for better control
Changes in 2.7:
- Fixed UI: header frame (╔═╗║║) now uses Cyan like the rest of the frame
- Status text (COMPLETED SUCCESSFULLY) remains colored (Green/Yellow/Red) for visual feedback
Changes in 2.6:
- Fixed UI: final statistics frame now uses consistent Cyan color throughout
- Fixed UI: added 2-space gap between label and value (prevents "installed:Windows:" merging)
- Fixed UI: category names (Temp, System) now right-aligned with PadLeft to match main labels
- Refactored: $labelWidth moved to parent scope for reuse in category alignment
Changes in 2.5:
- Fixed UI: subsection gray lines now match TITLE frame width (70 chars)
- Fixed UI: final statistics window alignment (emoji replaced with ASCII)
- Fixed UI: Write-StatLine width formula corrected (-5 → -3)
Changes in 2.4:
- UI improvements: consistent left indent (2 spaces) throughout the script
- UI improvements: major sections now have full frame (like banner)
- UI improvements: subsections keep original style (┌─ Title / └────)
- UI improvements: enhanced final statistics with status icons and colors
- UI improvements: header color reflects completion status (green/yellow/red)
- Removed 60-second auto-close timeout - window now waits indefinitely for keypress
Changes in 2.3:
- Fixed critical bug: TotalFreedBytes always showed 0 in final statistics
Root cause: Interlocked.Add doesn't work with hashtable elements via [ref] in PowerShell
Solution: Use simple += operator (synchronized hashtable handles thread-safety)
Changes in 2.2:
- Fixed TcpClient resource leak: now properly closed in finally block (prevents socket exhaustion)
- Fixed code region markers: 8 misplaced #region tags corrected for proper IDE navigation
- Fixed banner ASCII art: now displays "CLEAN" instead of incorrect "DREAM"
Changes in 2.1:
- Fixed Clear-EventLogs: exact match for Security log only (not all logs containing "Security")
- Fixed browser cache cleanup: additional profiles now get full cache set (Code Cache, GPUCache, etc.)
- Fixed Update-Applications: ErrorsCount++ now incremented when no internet
- Fixed Roslyn Temp cleanup: file patterns now handled correctly (not just directories)
- Fixed winget update count: works with custom sources (not just winget/msstore)
- Fixed interactive prompts: safe defaults in non-console environments (Scheduled Tasks, ISE)
- Fixed telemetry edition detection: uses EditionID registry (language-independent)
- Fixed final statistics: consistent box width (no visual glitches)
Changes in 2.0:
- Fixed Test-InternetConnection: uses TcpClient with 3s timeout (no VPN hangs)
- Fixed Clear-EventLogs: now checks $LASTEXITCODE for each wevtutil call
- Fixed winget ExitCode: strict check (any non-zero = error, not just empty output)
- Fixed Storage Sense: uses Get-ScheduledTask (language-independent status)
- Fixed Storage Sense: detects actual completion (wasRunning -> Ready transition)
- Fixed ReportOnly: no longer installs PSWindowsUpdate/NuGet modules
- Removed unused DriverUpdatesCount field from Stats
Changes in 1.9:
- Fixed progress bar: TotalSteps now calculated dynamically based on skip flags
- Fixed winget: source update skipped in ReportOnly mode, added ExitCode check
- Fixed winget: --include-unknown now used consistently for count and upgrade
- Fixed browser cache statistics: now measures actual freed space (before/after)
- Fixed Storage Sense: now waits for task completion instead of fixed sleep
- Fixed DNS flush: logs warning on unexpected result instead of false success
- Fixed WSL/Docker VHDX: now compacts all VHDX files regardless of distro list
- Moved Update-Progress calls after skip flag checks for accurate progress
Changes in 1.8:
- Fixed critical bug: $LogPath vs $script:LogPath in Start-WinClean and Show-FinalStatistics
- Fixed version inconsistency: unified all version references to single source
- Added browser cache size tracking to freed space statistics
- Fixed TotalSteps count (was 12, actual 7 steps)
- Improved winget update detection: language-independent parsing
Changes in 1.7:
- Improved internet connectivity check: HTTPS endpoints + ICMP fallback
- Fixed Show-Banner to display correct log path ($script:LogPath)
- Fixed Clear-SystemCaches: ReportOnly mode and size tracking for single files
Changes in 1.6:
- Added pause at end: window stays open 60 sec or until key press
(prevents window from closing before user can read results)
Changes in 1.5:
- Fixed visual glitch: clear progress bar before DISM output to prevent overlay
Changes in 1.4:
- Fixed Clear-PrivacyTraces: added -Recurse to Remove-Item to prevent confirmation
prompts when cleaning Recent folder (AutomaticDestinations, CustomDestinations)
Changes in 1.3:
- CRITICAL FIX: Clear-RecycleBin renamed to Clear-WinCleanRecycleBin to avoid
infinite recursion (stack overflow) caused by name collision with built-in cmdlet
Changes in 1.2:
- Fixed $script:LogPath scope (logging now works correctly)
- Fixed Clear-BrowserCaches respecting ReportOnly mode
- Fixed Windows.old path to use $env:SystemDrive instead of hardcoded C:
- Fixed NuGet: removed packages folder (not cache), kept only metadata caches
- Fixed Gradle: only delete safe build caches, not downloaded dependencies
- Fixed Windows Update services now properly restart via try/finally
- Fixed WSL --list output UTF-16LE parsing (removes null characters)
.PARAMETER SkipUpdates
Пропустить все обновления (Windows + winget)
.PARAMETER SkipCleanup
Пропустить все операции очистки (система, глубокая очистка, кэши разработчика,
Docker/WSL, Visual Studio). Отдельные -Skip*Cleanup - более точечные флаги внутри
.PARAMETER SkipRestore
Пропустить создание точки восстановления
.PARAMETER SkipDevCleanup
Пропустить очистку кэшей разработчика (npm, pip, nuget)
.PARAMETER SkipDockerCleanup
Пропустить очистку Docker/WSL
.PARAMETER SkipVSCleanup
Пропустить очистку Visual Studio
.PARAMETER SkipDiskCleanup
Пропустить только шаг Storage Sense / Disk Cleanup (штатная утилита Windows).
Этот шаг бывает самым долгим: на реальной рабочей станции cleanmgr занял 15 минут
из 18 и не успел завершиться, тогда как на чистой виртуальной машине те же
23 категории отрабатывают за 10 секунд. Причина разницы НЕ установлена: измерение
показывает лишь, что стоимость зависит от накопленного состояния машины. Остальная
очистка при этом выполняется - в отличие от -SkipCleanup, который гасит её целиком
.PARAMETER DisableTelemetry
Отключить телеметрию Windows (через групповую политику)
.PARAMETER ReportOnly
Только показать, что будет сделано (без выполнения)
.PARAMETER LogPath
Путь к файлу лога (по умолчанию: $env:TEMP\WinClean_<date>.log)
.PARAMETER ResultJsonPath
Путь для машиночитаемого итога прогона (JSON). Используется автотестами
и стендами; если не задан - JSON не создаётся
#>
#Requires -Version 7.1
#Requires -RunAsAdministrator
# PositionalBinding disabled (v2.15): stray positional arguments must fail loudly
# instead of silently binding to LogPath/ResultJsonPath and turning an intended
# dry run into a real one
[CmdletBinding(PositionalBinding = $false)]
param(
[switch]$SkipUpdates,
[switch]$SkipCleanup,
[switch]$SkipRestore,
[switch]$SkipDevCleanup,
[switch]$SkipDockerCleanup,
[switch]$SkipVSCleanup,
[switch]$SkipDiskCleanup,
[switch]$DisableTelemetry,
[switch]$ReportOnly,
[string]$LogPath,
[string]$ResultJsonPath
)
#region ═══════════════════════════════════════════════════════════════════════
# INITIALIZATION
#═══════════════════════════════════════════════════════════════════════════════
# Ensure UTF-8 encoding
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
$OutputEncoding = [System.Text.Encoding]::UTF8
$PSDefaultParameterValues['*:Encoding'] = 'utf8'
# Statistics storage (synchronized hashtable for safe concurrent access)
function New-RunStats {
<#
.SYNOPSIS
Builds a fresh per-run statistics object
.DESCRIPTION
v2.20: this was a literal assigned once when the script loaded, and Start-WinClean
reset only the phase buckets and the step counter - while the comment there
described "dot-source and call Start-WinClean twice" as the case being handled.
Everything else survived: freed bytes, per-category totals, update counts, warning
and error counts, RebootRequired, Aborted and StartTime. A second run in the same
session therefore reported the first run's bytes and errors, and computed its
duration from the moment the script was dot-sourced.
One definition, used both at load time and at the start of every run.
#>
return [hashtable]::Synchronized(@{
TotalFreedBytes = [long]0
FreedByCategory = @{}
WindowsUpdatesCount = 0
# v2.19: renamed from AppUpdatesCount. winget upgrade --all cannot report how many
# apps actually installed (it silently skips pinned/manifest-less/UAC-cancelled ones),
# so we only ever know how many it OFFERED. Naming it "installed" was a false claim.
AppUpdatesOffered = 0
# v2.21: why the app half of the Updates phase produced the count it did. Added because demoting a
# missing winget from error to warning removed the only machine-readable way to tell
# "checked, nothing to upgrade" from "could not check at all" - both are
# AppUpdatesOffered = 0 with WarningsCount incremented by one of many possible causes.
# 'not-run' | 'checked' | 'check-failed' | 'skipped-parameter' | 'skipped-offline'
# | 'skipped-no-winget'
AppUpdatesStatus = 'not-run'
WarningsCount = 0
ErrorsCount = 0
RebootRequired = $false
StartTime = Get-Date
CurrentStep = 0
TotalSteps = 0 # Calculated dynamically in Start-WinClean
# v2.16: tri-state string, 'disabled' / 'enabled' / 'unknown'. Never a boolean:
# 'unknown' must not be mistaken for a verified state by consumers of the JSON
ControlledFolderAccess = 'unknown'
Aborted = $null # v2.17: set when the run stops before finishing
# v2.20: the wait expired without cleanmgr either exiting or being seen to go idle,
# so it was left running. The totals may be partial and must not be read as final.
# Note this does NOT assert it was observed working - the same branch catches a
# machine where activity could not be measured at all (raised in review).
DiskCleanupPending = $false
# v2.22: how the Storage Sense / Disk Cleanup step actually ended. DiskCleanupPending
# alone could not tell "still visibly deleting" from "resident but doing nothing", and
# reported both as pending - so a cleanup nothing had been observed to be doing was
# published as partial.
# Same shape and reasoning as AppUpdatesStatus (v2.21): when a boolean starts covering
# two different truths, the fix is a status, not a cleverer boolean.
# 'not-run' | 'skipped-parameter' | 'skipped-cleanup-group' | 'skipped-report-only' |
# 'storage-sense' | 'running' | 'completed' | 'idle-resident' | 'timeout' |
# 'not-armed' | 'start-failed' | 'exit-nonzero'
DiskCleanupStatus = 'not-run'
# v2.17 (p.11 of the audit): which top-level phases ran to completion vs threw.
# Before this, one exception anywhere in the run silently skipped every phase
# after it - Developer Cleanup, Docker/WSL, Visual Studio, Deep System Cleanup,
# the disk space report, Telemetry - with only a single generic "Critical error"
# in the log to show for it.
# v2.19: these are a DISPATCH status, not an outcome. Completed = the phase action
# was invoked and returned without an uncaught exception (NOT "succeeded" - e.g.
# Preparation stays Completed even when the restore point genuinely failed, because
# New-SystemRestorePoint catches that and returns $false). Skipped = a skip flag
# suppressed the phase before it ran. Failed = the action threw. For a non-aborted
# run the three are disjoint and their union is exactly the known phase set.
PhasesCompleted = @()
PhasesFailed = @()
PhasesSkipped = @()
})
}
$script:Stats = New-RunStats
# Progress activities seen so far, so all of them can be closed at the end (v2.16)
$script:ProgressActivities = @()
# Memoized Test-InternetConnection result for the whole run (v2.17, p.5 of the audit):
# the check costs up to ~15s offline and is called from both halves of the Updates phase
$script:InternetConnectionCache = $null
# Latched by Write-Log when the log file cannot be written, so the failure is reported
# once instead of on every call - and surfaces in the result JSON as LoggingDegraded
$script:LogWriteFailed = $false
# Inner width of every framed section. v2.22: was the literal 70 repeated in four places;
# Start-WinClean now derives it once from the actual console. 70 stays the default so a
# host whose width cannot be read looks exactly as it always did.
$script:BoxWidth = 70
# Initialize log path (script scope for access in functions)
if (-not $LogPath) {
$script:LogPath = Join-Path $env:TEMP "WinClean_$((Get-Date).ToString('yyyyMMdd_HHmmss')).log"
} else {
$script:LogPath = $LogPath
# Ensure the parent directory exists for custom log paths (v2.14)
$logDir = Split-Path -Path $script:LogPath -Parent
if ($logDir -and -not (Test-Path -LiteralPath $logDir)) {
New-Item -ItemType Directory -Path $logDir -Force -ErrorAction SilentlyContinue | Out-Null
}
}
# Script version (single source of truth for version checking)
$script:Version = "2.22"
# Protected paths that should never be deleted
$script:ProtectedPaths = @(
$env:SystemRoot,
"$env:SystemRoot\System32",
$env:ProgramFiles,
${env:ProgramFiles(x86)},
$env:USERPROFILE,
"$env:SystemDrive\Users",
"$env:SystemDrive\Program Files",
"$env:SystemDrive\Program Files (x86)"
)
#endregion
#region ═══════════════════════════════════════════════════════════════════════
# LOGGING FUNCTIONS
#═══════════════════════════════════════════════════════════════════════════════
function Write-LogFileLine {
<#
.SYNOPSIS
Appends one line to the log file, degrading instead of throwing
.DESCRIPTION
v2.22, raised in external review: extracted from Write-Log so that EVERY write to
the log file - including the header, which Start-WinClean emits before its main
try/finally exists - degrades the same way instead of each caller inventing its
own fault tolerance.
The header used to be two bare Out-File calls. Measured, not assumed: six of seven
bad log paths make Out-File throw a TERMINATING error even though the script leaves
ErrorActionPreference at Continue (missing directory, path is a directory, invalid
characters, colon in the name, over-long path, unreachable UNC; only a reserved
device name did not). Thrown there, before the safety net, the exception escaped
Start-WinClean entirely: no result JSON, no final summary, no exit-code accounting,
and none of the maintenance the run was started for - all because of the log.
A log that cannot be written is a degraded run, never a failed one.
#>
param(
[Parameter(Mandatory)]
[AllowEmptyString()]
[string]$Line,
# The header line starts a fresh file. The Out-File call this replaced had no
# -Append, so it truncated on every run; preserved deliberately rather than lost
# in the refactor, or a custom -LogPath reused across runs would accumulate them
# all into one file and the log would no longer describe a single run.
[switch]$StartNewFile
)
# v2.17 (p.7 of the audit): Out-File used to open, seek to end, write and close the
# file on every single call - Write-Log fires hundreds of times per run. A StreamWriter
# kept open for the run avoids that, with AutoFlush so each line still lands on disk
# immediately (same durability as before, just cheaper). FileShare.Delete matters for
# tests: they Remove-Item the log path in AfterAll while this writer may still be the
# last one that touched it.
try {
if ($StartNewFile -or -not $script:LogWriter -or $script:LogWriterPath -ne $script:LogPath) {
if ($script:LogWriter) { $script:LogWriter.Dispose() }
$mode = if ($StartNewFile) { [System.IO.FileMode]::Create } else { [System.IO.FileMode]::Append }
$fileStream = [System.IO.File]::Open(
$script:LogPath, $mode, [System.IO.FileAccess]::Write,
([System.IO.FileShare]::ReadWrite -bor [System.IO.FileShare]::Delete))
$script:LogWriter = [System.IO.StreamWriter]::new($fileStream, [System.Text.Encoding]::UTF8)
$script:LogWriter.AutoFlush = $true
$script:LogWriterPath = $script:LogPath
}
$script:LogWriter.WriteLine($Line)
} catch {
# v2.20: this used to be an empty catch, so a log that stopped being written
# (full volume, revoked permissions, the v2.14 case where cleanup deleted the
# log out from under us) was invisible: destructive work carried on, the final
# JSON said ErrorsCount=0, and LogPath pointed at a truncated file.
#
# Latched: one console line, not one per call - Write-Log fires hundreds of
# times per run. Deliberately Write-Host and not Write-Log, which would
# recurse straight back into this catch.
# v2.20, corrected in review: drop the writer so the NEXT call reopens it.
# Without this the guard above stays satisfied by a dead writer object and
# every later line is silently discarded for the rest of the run - the empty
# catch would simply have moved from the first failure to all the others.
try { if ($script:LogWriter) { $script:LogWriter.Dispose() } } catch { }
$script:LogWriter = $null
$script:LogWriterPath = $null
if (-not $script:LogWriteFailed) {
$script:LogWriteFailed = $true
$script:Stats.WarningsCount++
Write-Host " [WARN] Log file could not be written ($($_.Exception.Message)) - the run continues, but $($script:LogPath) may be incomplete" -ForegroundColor Yellow
}
}
}
function Write-Log {
<#
.SYNOPSIS
Writes colored output to console and plain text to log file
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$Message,
[ValidateSet('INFO', 'SUCCESS', 'WARNING', 'ERROR', 'TITLE', 'SECTION', 'DETAIL')]
[string]$Level = 'INFO',
[switch]$NoNewLine,
[switch]$NoTimestamp,
[switch]$NoLog
)
# Consistent left indent for all output (matches banner style)
$indent = " "
$boxWidth = $script:BoxWidth # Inner width for framed sections (v2.22: adaptive)
$timestamp = (Get-Date).ToString('HH:mm:ss')
$logMessage = "[$timestamp] [$Level] $Message"
if (-not $NoLog) {
Write-LogFileLine -Line $logMessage
}
# Console output with colors
$colors = @{
INFO = @{ Tag = 'Cyan'; Message = 'White' }
SUCCESS = @{ Tag = 'Green'; Message = 'White' }
WARNING = @{ Tag = 'Yellow'; Message = 'Yellow' }
ERROR = @{ Tag = 'Red'; Message = 'Red' }
TITLE = @{ Tag = 'Magenta'; Message = 'Magenta' }
SECTION = @{ Tag = 'Cyan'; Message = 'Cyan' }
DETAIL = @{ Tag = 'DarkGray';Message = 'Gray' }
}
$tagColors = $colors[$Level]
switch ($Level) {
'TITLE' {
# Full frame for major sections (like banner style, but Magenta)
$titleText = $Message.ToUpper()
$padding = [math]::Max(0, $boxWidth - $titleText.Length)
$leftPad = [math]::Floor($padding / 2)
$rightPad = $padding - $leftPad
$centeredTitle = (" " * $leftPad) + $titleText + (" " * $rightPad)
Write-Host ""
Write-Host "$indent╔$("═" * $boxWidth)╗" -ForegroundColor $tagColors.Tag
Write-Host "$indent║$centeredTitle║" -ForegroundColor $tagColors.Tag
Write-Host "$indent╚$("═" * $boxWidth)╝" -ForegroundColor $tagColors.Tag
}
'SECTION' {
# Subsection header (keep original style with indent)
Write-Host ""
Write-Host "$indent┌─ " -NoNewline -ForegroundColor DarkGray
Write-Host $Message -ForegroundColor $tagColors.Message
Write-Host "$indent└$("─" * $boxWidth)" -ForegroundColor DarkGray
}
'DETAIL' {
# Detail line with vertical bar
Write-Host "$indent │ " -NoNewline -ForegroundColor DarkGray
Write-Host $Message -ForegroundColor $tagColors.Message -NoNewline:$NoNewLine
if (-not $NoNewLine) { Write-Host "" }
}
default {
# Standard log line with timestamp and tag
Write-Host $indent -NoNewline
if (-not $NoTimestamp) {
Write-Host "[$timestamp] " -NoNewline -ForegroundColor DarkGray
}
$tagText = switch ($Level) {
'INFO' { '[INFO] ' }
'SUCCESS' { '[OK] ' }
'WARNING' { '[WARN] ' }
'ERROR' { '[ERROR] ' }
}
Write-Host $tagText -NoNewline -ForegroundColor $tagColors.Tag
Write-Host $Message -ForegroundColor $tagColors.Message -NoNewline:$NoNewLine
if (-not $NoNewLine) { Write-Host "" }
}
}
}
function Update-Progress {
<#
.SYNOPSIS
Updates progress bar and step counter
#>
param(
[string]$Activity,
[string]$Status = "Processing..."
)
$script:Stats.CurrentStep++
$percent = [math]::Min(100, [math]::Round(($script:Stats.CurrentStep / $script:Stats.TotalSteps) * 100))
# Remember the activity so Clear-AllProgress can close it later (v2.16)
if ($Activity -and $script:ProgressActivities -notcontains $Activity) {
$script:ProgressActivities += $Activity
}
Write-Progress -Activity $Activity -Status $Status -PercentComplete $percent
}
function Clear-AllProgress {
<#
.SYNOPSIS
Closes every progress bar before the final report is printed
.DESCRIPTION
v2.16: "Write-Progress -Activity 'Complete' -Completed" only closed an activity
that never existed, so leftover bars stayed on screen under the summary - both
our own (seven different activities are used) and foreign ones from cmdlets such
as Clear-RecycleBin, whose activity name is not ours to know. Clearing by Id
covers those: an unused Id is simply a no-op.
v2.17 (p.16 of the audit): 0..10 was eyeballed, not derived from anything. Widened
to 0..30 - ForEach-Object -Parallel and nested cmdlets can allocate Ids well past
10, and clearing an unused Id costs nothing.
#>
foreach ($activity in $script:ProgressActivities) {
Write-Progress -Activity $activity -Completed -ErrorAction SilentlyContinue
}
for ($id = 0; $id -le 30; $id++) {
Write-Progress -Id $id -Activity ' ' -Completed -ErrorAction SilentlyContinue
}
}
#endregion
#region ═══════════════════════════════════════════════════════════════════════
# HELPER FUNCTIONS
#═══════════════════════════════════════════════════════════════════════════════
function Get-RunMarkerPath {
Join-Path $env:TEMP 'WinClean.recovery-marker.json'
}
function Set-RunMarker {
<#
.SYNOPSIS
Records that a risky, hard-to-undo operation is about to start
.DESCRIPTION
v2.17 (p.13 of the audit): Ctrl+C already unwinds through try/finally - the
gap is a HARD kill (taskkill /F, a closed terminal, a reset VM), which skips
every finally block, including the ones that restore
SystemRestorePointCreationFrequency or restart wuauserv/bits. This marker lets
the next run detect that and recover - a plain "is the value 0 right now"
check cannot tell an interrupted run from a value the user or IT policy set on
purpose, and blindly overwriting that would be the wrong kind of surprise.
Best-effort: a failed marker write must never block the real operation.
#>
param(
[Parameter(Mandatory)][string]$Phase,
[hashtable]$Data = @{}
)
try {
$marker = [ordered]@{ Phase = $Phase; Pid = $PID; Timestamp = (Get-Date).ToString('o') }
foreach ($key in $Data.Keys) { $marker[$key] = $Data[$key] }
$marker | ConvertTo-Json -Compress | Set-Content -LiteralPath (Get-RunMarkerPath) -Encoding utf8 -ErrorAction Stop
} catch { }
}
function Clear-RunMarker {
Remove-Item -LiteralPath (Get-RunMarkerPath) -Force -ErrorAction SilentlyContinue
}
function Restore-RestorePointFrequency {
<#
.SYNOPSIS
Puts SystemRestorePointCreationFrequency back to $PreviousValue, if it is still 0
.DESCRIPTION
Shared by the inline timeout path in New-SystemRestorePoint and by
Invoke-StaleMarkerRecovery, so both behave identically. Only acts when the value
is currently 0 (i.e. still holding this script's override) - if something else
has since set a real value, that is left alone.
.OUTPUTS
[bool] $true when nothing needs doing or the restore succeeded, $false on failure
#>
param($PreviousValue)
try {
$srKey = 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\SystemRestore'
$current = (Get-ItemProperty -Path $srKey -Name SystemRestorePointCreationFrequency -ErrorAction SilentlyContinue).SystemRestorePointCreationFrequency
if ($current -ne 0) { return $true } # already a real value - not ours to touch
if ($null -ne $PreviousValue) {
Set-ItemProperty -Path $srKey -Name SystemRestorePointCreationFrequency -Value $PreviousValue -Type DWord -Force -ErrorAction Stop
} else {
Remove-ItemProperty -Path $srKey -Name SystemRestorePointCreationFrequency -ErrorAction Stop
}
return $true
} catch {
Write-Log "Could not restore SystemRestorePointCreationFrequency: $_" -Level WARNING
return $false
}
}
function Invoke-StaleMarkerRecovery {
<#
.SYNOPSIS
Recovers system state left behind by a hard-killed previous run, if any
.DESCRIPTION
v2.17 (p.13 of the audit). Called once at the start of a run. A marker left by
THIS run's own process id is not evidence of anything (re-entrant call, or a
race) - only a marker from a different process means the previous run never
reached its cleanup.
The marker is kept when recovery FAILS, so the next run can retry instead of
leaving the damage in place forever with no record of it.
Known limitation, deliberately not solved here: a PID is not a durable identity
(Windows recycles them, and two concurrent runs would each see the other as
"foreign"). Both cases need a second WinClean running as administrator at the
same time, which the script does not support anyway; the recovery actions are
also written to be no-ops when there is nothing to repair.
#>
$markerPath = Get-RunMarkerPath
if (-not (Test-Path -LiteralPath $markerPath -ErrorAction SilentlyContinue)) { return }
try {
$marker = Get-Content -LiteralPath $markerPath -Raw -ErrorAction Stop | ConvertFrom-Json
} catch {
Remove-Item -LiteralPath $markerPath -Force -ErrorAction SilentlyContinue
return
}
if ($marker.Pid -eq $PID) { return }
Write-Log "Recovery marker found from an interrupted previous run (phase: $($marker.Phase), pid $($marker.Pid)) - checking for leftover state" -Level WARNING
$script:Stats.WarningsCount++
$recovered = $true
switch ($marker.Phase) {
'RestorePointFrequencyOverride' {
$recovered = Restore-RestorePointFrequency -PreviousValue $marker.PreviousValue
if ($recovered) {
Write-Log "Checked SystemRestorePointCreationFrequency after the interrupted run" -Level INFO
}
}
'WUServiceStop' {
# Only services this script actually stopped are restarted. Starting every
# stopped service would fight an administrator who disabled one on purpose.
foreach ($svcName in @($marker.ServicesToRestart)) {
if (-not $svcName) { continue }
try {
$svc = Get-Service -Name $svcName -ErrorAction SilentlyContinue
if ($svc -and $svc.Status -eq 'Stopped') {
Start-Service -Name $svcName -ErrorAction Stop
Write-Log "Restarted $svcName, left stopped by the interrupted run" -Level INFO
}
} catch {
Write-Log "Could not restart $svcName : $_" -Level WARNING
$recovered = $false
}
}
}
}
if ($recovered) {
Remove-Item -LiteralPath $markerPath -Force -ErrorAction SilentlyContinue
} else {
Write-Log "Recovery incomplete - keeping the marker so the next run retries" -Level WARNING
}
}
function Test-InteractiveConsole {
<#
.SYNOPSIS
Checks if running in an interactive console environment
.DESCRIPTION
Returns $false for Scheduled Tasks, ISE, remote sessions, etc.
Used to safely skip [Console]::KeyAvailable calls that would throw exceptions
#>
try {
# Check if we're in ConsoleHost and have a valid console window
if ($Host.Name -ne 'ConsoleHost') {
return $false
}
# Try to access console properties - will throw in non-console environments
$null = [Console]::WindowWidth
return $true
} catch {
return $false
}
}
function Get-ConsoleWidth {
<#
.SYNOPSIS
The console width in columns, or 0 when it cannot be determined
.DESCRIPTION
v2.22. Returns 0 when there is no console to measure - a scheduled task, a service,
or any host without one - and the caller then falls back to the historical fixed
width rather than laying out against a guess.
Measured, because the obvious assumption is wrong: REDIRECTING output does NOT make
this return 0. $Host.UI.RawUI reads the attached console, not stdout, so
`pwsh -File script.ps1 *> out.txt` from a terminal still reports that terminal's
width. The smoke test therefore exercises the adaptive width, not the 70 fallback.
#>
try {
$width = $Host.UI.RawUI.WindowSize.Width
if ($width -gt 0) { return [int]$width }
} catch { }
return 0
}
function Get-BoxWidth {
<#
.SYNOPSIS
Inner width for framed sections, derived from the console width
.DESCRIPTION
v2.22, pure so the bounds are testable. A box is printed as two spaces of indent,
a border character, the inner width, and a closing border character - so it needs
ConsoleWidth-4 at the very most, and 6 is subtracted to keep a margin away from
the wrap column.
Bounded both ways on purpose:
- never below 70, the width every previous version used. A narrow console cannot
be laid out well either way, and shrinking below 70 would change how WinClean
looks for everyone who has been reading it at 70 for ten releases.
- never above 90. Raising the ceiling further makes the frames span the screen on
a wide monitor, which reads worse rather than better - the user asked for
"somewhat wider", not "as wide as it goes".
#>
param([int]$ConsoleWidth)
if ($ConsoleWidth -le 0) { return 70 }
return [math]::Max(70, [math]::Min(90, $ConsoleWidth - 6))
}
function Expand-ConsoleWindow {
<#
.SYNOPSIS
Best-effort widening of the console window at startup
.DESCRIPTION
v2.22. The desktop shortcut opens conhost at its 120-column default, and winget's
upgrade table needs about 140 on a localised system - so it wrapped, and every
wrapped row landed across the script's own output. Measured on the reporting
machine: window 120x30, buffer 120x9001, maximum physical window 3824 columns.
There was room; nobody had asked for it.
Deliberately done here rather than by writing console properties into the .lnk:
the shortcut route only fixes the shortcut (and NT_CONSOLE_PROPS is a binary blob
WScript.Shell will not write), while this helps every way of starting the script.
Best-effort by design. Windows Terminal ignores or refuses programmatic resizing,
redirected hosts throw - none of that is worth a warning, let alone a failed run.
The buffer is grown before the window because a window may never exceed its
buffer; the reverse order fails.
#>
param([int]$DesiredWidth = 140)
try {
$raw = $Host.UI.RawUI
$current = $raw.WindowSize
if ($current.Width -le 0 -or $current.Width -ge $DesiredWidth) { return }
# Never ask for more than the screen can physically show
$target = [math]::Min($DesiredWidth, $raw.MaxPhysicalWindowSize.Width)
if ($target -le $current.Width) { return }
$buffer = $raw.BufferSize
if ($buffer.Width -lt $target) {
$buffer.Width = $target
$raw.BufferSize = $buffer
}
$window = $raw.WindowSize
$window.Width = $target
$raw.WindowSize = $window
} catch {
# Host refused; the run continues at whatever width it already had
}
}
function Test-InternetConnection {
<#
.SYNOPSIS
Проверяет доступ к интернету через TCP-соединения с таймаутом
.DESCRIPTION
Использует TcpClient с явным таймаутом (3 сек) вместо Test-NetConnection,
который может зависать на 20-30 секунд при VPN или нестабильном соединении.
Результат кэшируется на весь прогон (v2.17): вызывается из обеих половин фазы
Updates (Windows Update, Applications Update), до 15 сек на офлайн-машине каждый раз.
Сетевая связность внутри одного прогона скрипта не меняется настолько часто,
чтобы повторная проверка была оправдана. -Force сбрасывает кэш.
#>
param([switch]$Force)
if (-not $Force -and $null -ne $script:InternetConnectionCache) {
return $script:InternetConnectionCache
}
$targets = @(
@{ Host = 'www.microsoft.com'; Port = 443 }
@{ Host = 'api.github.com'; Port = 443 }
@{ Host = 'cdn.winget.microsoft.com'; Port = 443 }
)
$timeoutMs = 3000 # 3 секунды таймаут на каждое соединение
foreach ($target in $targets) {
$tcpClient = $null
try {
$tcpClient = New-Object System.Net.Sockets.TcpClient
$connect = $tcpClient.BeginConnect($target.Host, $target.Port, $null, $null)
$success = $connect.AsyncWaitHandle.WaitOne($timeoutMs, $false)
if ($success -and $tcpClient.Connected) {
$tcpClient.EndConnect($connect)
$script:InternetConnectionCache = $true
return $true
}
} catch {
} finally {
# Always close TcpClient to prevent resource leaks (fixed in v2.2)
if ($tcpClient) {
$tcpClient.Close()
}
}
}
# Запасной вариант: ICMP (может быть заблокирован в некоторых сетях)
$dnsServers = @('8.8.8.8', '1.1.1.1', '208.67.222.222')
foreach ($dns in $dnsServers) {
if (Test-Connection -ComputerName $dns -Count 1 -Quiet -TimeoutSeconds 2 -ErrorAction SilentlyContinue) {
$script:InternetConnectionCache = $true
return $true
}
}
$script:InternetConnectionCache = $false
return $false
}
function Test-PSGalleryConnection {
<#
.SYNOPSIS
Проверяет доступность PowerShell Gallery перед установкой модулей
.DESCRIPTION
Использует Invoke-WebRequest с коротким таймаутом для проверки доступности
powershellgallery.com. Более специфичная проверка чем общий Test-InternetConnection.
.OUTPUTS
[bool] $true если PowerShell Gallery доступен, $false в противном случае
#>
try {
# Check PSGallery API endpoint (faster than main page)
# Note: -UseBasicParsing removed - it was deprecated in PS 6.0 and removed in PS 7.4+