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
using System.Diagnostics;
using System.IO;
using System.Text.Json;
using System.Text.RegularExpressions;
using System.Xml.Linq;
namespace AdamsToolkit.Core;
/// <summary>App da Microsoft Store / Appx (Xbox, Cortana, Clipchamp, Teams pessoal…).</summary>
public class AppxPackage
{
public string Name { get; set; } = ""; // ex.: Microsoft.XboxApp
public string DisplayName { get; set; } = ""; // nome legível (manifest) ou Name limpo
public string PackageFullName { get; set; } = "";
public string PackageFamilyName { get; set; } = "";
public string Version { get; set; } = "";
public string Publisher { get; set; } = ""; // "CN=Microsoft Corporation, …" → só o CN
public string InstallLocation { get; set; } = "";
public bool NonRemovable { get; set; } // Windows recusa remover (Store, Edge, Settings…)
public string SignatureKind { get; set; } = ""; // Store | System | Developer | None
public string LogoPath { get; set; } = ""; // png absoluto quando encontrado
public long SizeBytes { get; set; }
public bool IsSystem => SignatureKind.Equals("System", StringComparison.OrdinalIgnoreCase);
}
/// <summary>
/// Desinstalador de apps do Windows (Store/Appx). Tudo via PowerShell
/// (<c>Get-AppxPackage</c> / <c>Remove-AppxPackage</c>) para o utilizador atual — sem admin.
/// Apps NonRemovable (Store, Edge, Definições, Explorador) aparecem mas bloqueadas:
/// o Windows recusa e podem partir o sistema; não se tenta contornar.
/// </summary>
public static class AppxUninstaller
{
// enums/Version saem como número/objeto no ConvertTo-Json do PS 5.1 → forçar strings
private const string ListScript = @"
$ErrorActionPreference='SilentlyContinue'
Get-AppxPackage | Where-Object { -not $_.IsFramework -and -not $_.IsResourcePackage } | ForEach-Object {
[pscustomobject]@{
Name=[string]$_.Name; PackageFullName=[string]$_.PackageFullName; PackageFamilyName=[string]$_.PackageFamilyName
Version=[string]$_.Version; Publisher=[string]$_.Publisher; InstallLocation=[string]$_.InstallLocation
NonRemovable=[bool]$_.NonRemovable; SignatureKind=[string]$_.SignatureKind
}
} | ConvertTo-Json -Compress
";
// pacotes que nunca devem ser removidos mesmo que o Windows deixe (mata Start/Definições/Store)
private static readonly string[] Protected =
{
"Microsoft.WindowsStore", "Microsoft.Windows.ShellExperienceHost", "Microsoft.Windows.StartMenuExperienceHost",
"Microsoft.AAD.BrokerPlugin", "Microsoft.Windows.Search", "Microsoft.UI.Xaml", "Microsoft.VCLibs",
"Microsoft.NET.Native", "Microsoft.DesktopAppInstaller", "Microsoft.StorePurchaseApp", "Microsoft.SecHealthUI",
"MicrosoftWindows.Client", "Microsoft.Windows.CloudExperienceHost", "Microsoft.LockApp", "Microsoft.Win32WebViewHost",
"Microsoft.WindowsAppRuntime", "Microsoft.Windows.ContentDeliveryManager", "Microsoft.WindowsTerminal",
};
public static bool IsProtected(AppxPackage p) =>
p.NonRemovable || Protected.Any(x => p.Name.StartsWith(x, StringComparison.OrdinalIgnoreCase));
public static async Task<List<AppxPackage>> EnumerateAsync()
{
var json = await RunPsAsync(ListScript, TimeSpan.FromSeconds(90));
var list = new List<AppxPackage>();
Log($"enumerate: {json.Length} bytes; head={json[..Math.Min(300, json.Length)]}");
if (string.IsNullOrWhiteSpace(json)) return list;
try
{
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
var items = root.ValueKind == JsonValueKind.Array ? root.EnumerateArray().ToList() : new() { root };
foreach (var e in items)
{
var p = new AppxPackage
{
Name = Str(e, "Name"),
PackageFullName = Str(e, "PackageFullName"),
PackageFamilyName = Str(e, "PackageFamilyName"),
Version = Str(e, "Version"),
Publisher = CleanPublisher(Str(e, "Publisher")),
InstallLocation = Str(e, "InstallLocation"),
SignatureKind = e.TryGetProperty("SignatureKind", out var sk) && sk.ValueKind == JsonValueKind.Number
? (sk.GetInt32() switch { 1 => "Developer", 2 => "Enterprise", 3 => "Store", 4 => "System", _ => "None" })
: Str(e, "SignatureKind"),
NonRemovable = e.TryGetProperty("NonRemovable", out var nr) && nr.ValueKind == JsonValueKind.True,
};
if (p.Name.Length == 0) continue;
// só o que o utilizador pode mesmo remover: apps da Store/dev. Componentes
// internos assinados "System" (OOBE, Search, PinningConfirmationDialog, hosts
// do shell…) e protegidas ficam de fora — não são "apps" e o Windows recusa.
if (IsProtected(p) || p.IsSystem) continue;
if (p.SignatureKind.Equals("Developer", StringComparison.OrdinalIgnoreCase) == false &&
p.SignatureKind.Equals("Store", StringComparison.OrdinalIgnoreCase) == false &&
p.SignatureKind.Equals("Enterprise", StringComparison.OrdinalIgnoreCase) == false) continue;
ReadManifest(p);
list.Add(p);
}
}
catch { }
list.Sort((a, b) => string.Compare(a.DisplayName, b.DisplayName, StringComparison.OrdinalIgnoreCase));
Log($"enumerate: {list.Count} removíveis");
return list;
}
private static readonly string LogFile = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "AdamsToolkit", "appx.log");
private static void Log(string msg)
{
try
{
Directory.CreateDirectory(Path.GetDirectoryName(LogFile)!);
File.AppendAllText(LogFile, $"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] {msg}\n");
}
catch { }
}
/// <summary>
/// Remove o pacote. 1º tenta para o utilizador atual (sem admin); se o Windows recusar
/// (pacote provisionado / all-users / 0x80073CFA), pede UAC e remove para todos os
/// utilizadores + desprovisiona. Devolve null = OK, senão mensagem de erro.
/// </summary>
private static string Q(string v) => v.Replace("'", "''"); // literal PowerShell single-quoted
public static async Task<string?> RemoveAsync(AppxPackage p)
{
if (IsProtected(p)) return "Esta app faz parte do Windows e não pode ser removida.";
var script = $"$ErrorActionPreference='Stop'\ntry {{ Remove-AppxPackage -Package '{Q(p.PackageFullName)}' ; 'OK' }} catch {{ 'ERR: ' + $_.Exception.Message }}";
var outp = (await RunPsAsync(script, TimeSpan.FromMinutes(3))).Trim();
Log($"remove {p.PackageFullName} (user) → {outp}");
if (outp.StartsWith("OK") && !await ExistsAsync(p)) return null;
// ainda existe ou falhou → tentar elevado (todos os utilizadores + provisioning)
var err = outp.StartsWith("ERR: ") ? outp[5..].Split('\n')[0].Trim() : "";
var elevated = await RemoveElevatedAsync(p);
Log($"remove {p.PackageFullName} (admin) → {elevated ?? "OK"}");
if (elevated == null && !await ExistsAsync(p)) return null;
return elevated ?? (err.Length > 0 ? err : "O Windows não removeu o pacote.");
}
/// <summary>PowerShell elevado (UAC). Sem stdout — resultado vai para um ficheiro temp.</summary>
private static async Task<string?> RemoveElevatedAsync(AppxPackage p)
{
var result = Path.Combine(Path.GetTempPath(), $"adams-appx-r-{Guid.NewGuid():N}.txt");
var script = Path.Combine(Path.GetTempPath(), $"adams-appx-e-{Guid.NewGuid():N}.ps1");
var body = $@"$ErrorActionPreference='Continue'
$out=@()
try {{ Remove-AppxPackage -Package '{Q(p.PackageFullName)}' -AllUsers -ErrorAction Stop; $out+='alluser OK' }} catch {{ $out+='alluser ERR: '+$_.Exception.Message }}
try {{ Get-AppxProvisionedPackage -Online | Where-Object {{ $_.DisplayName -eq '{Q(p.Name)}' }} | Remove-AppxProvisionedPackage -Online -ErrorAction Stop | Out-Null; $out+='prov OK' }} catch {{ $out+='prov ERR: '+$_.Exception.Message }}
try {{ Remove-AppxPackage -Package '{Q(p.PackageFullName)}' -ErrorAction Stop; $out+='user OK' }} catch {{ $out+='user ERR: '+$_.Exception.Message }}
Set-Content -Path '{result}' -Value ($out -join ""`n"") -Encoding UTF8
";
try
{
await File.WriteAllTextAsync(script, body, System.Text.Encoding.UTF8);
var psi = new ProcessStartInfo("powershell.exe",
$"-NoProfile -ExecutionPolicy Bypass -NonInteractive -WindowStyle Hidden -File \"{script}\"")
{ UseShellExecute = true, Verb = "runas", WindowStyle = ProcessWindowStyle.Hidden };
Process? proc;
try { proc = Process.Start(psi); }
catch (System.ComponentModel.Win32Exception) { return "Precisa de administrador (UAC cancelado)."; }
if (proc == null) return "PowerShell indisponível.";
using var cts = new CancellationTokenSource(TimeSpan.FromMinutes(3));
try { await proc.WaitForExitAsync(cts.Token); } catch (OperationCanceledException) { try { proc.Kill(true); } catch { } return "Demorou demasiado."; }
var txt = File.Exists(result) ? (await File.ReadAllTextAsync(result)).Trim() : "";
Log($"elevated out: {txt.Replace('\n', '|')}");
if (txt.Contains("alluser OK") || txt.Contains("user OK")) return null;
var first = txt.Split('\n').FirstOrDefault(l => l.Contains("ERR: "));
return first == null ? "Sem resposta do PowerShell elevado." : first[(first.IndexOf("ERR: ") + 5)..].Trim();
}
catch (Exception ex) { return ex.Message; }
finally { try { File.Delete(script); File.Delete(result); } catch { } }
}
/// <summary>Confirma se o pacote ainda existe (depois de remover).</summary>
public static async Task<bool> ExistsAsync(AppxPackage p)
{
var outp = await RunPsAsync($"$ErrorActionPreference='SilentlyContinue'\nif (Get-AppxPackage -Name '{Q(p.Name)}') {{ 'YES' }} else {{ 'NO' }}", TimeSpan.FromSeconds(30));
return outp.Trim().StartsWith("YES");
}
// ---- helpers ----
private static string Str(JsonElement e, string name) =>
e.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() ?? "" : "";
private static string CleanPublisher(string s)
{
var m = Regex.Match(s, @"CN=([^,]+)");
return m.Success ? m.Groups[1].Value.Trim().Trim('"') : s;
}
/// <summary>Nome legível + logo a partir do AppxManifest.xml (DisplayName ms-resource: → fallback ao Name).</summary>
private static void ReadManifest(AppxPackage p)
{
p.DisplayName = FriendlyFromName(p.Name);
try
{
var manifest = Path.Combine(p.InstallLocation, "AppxManifest.xml");
if (p.InstallLocation.Length == 0 || !File.Exists(manifest)) return;
var doc = XDocument.Load(manifest);
var props = doc.Root?.Elements().FirstOrDefault(x => x.Name.LocalName == "Properties");
var dn = props?.Elements().FirstOrDefault(x => x.Name.LocalName == "DisplayName")?.Value?.Trim() ?? "";
if (dn.Length > 0 && !dn.StartsWith("ms-resource", StringComparison.OrdinalIgnoreCase)) p.DisplayName = dn;
var logo = props?.Elements().FirstOrDefault(x => x.Name.LocalName == "Logo")?.Value?.Trim() ?? "";
if (logo.Length > 0) p.LogoPath = ResolveLogo(p.InstallLocation, logo);
}
catch { }
}
private static string FriendlyFromName(string name)
{
var last = name.Split('.').Last();
last = Regex.Replace(last, "([a-z])([A-Z])", "$1 $2");
return last;
}
/// <summary>Assets/StoreLogo.png raramente existe tal e qual — o Windows guarda variantes .scale-100/.targetsize-48.</summary>
private static string ResolveLogo(string root, string rel)
{
try
{
var full = Path.Combine(root, rel.Replace('/', '\\'));
if (File.Exists(full)) return full;
var dir = Path.GetDirectoryName(full) ?? root;
var stem = Path.GetFileNameWithoutExtension(full);
if (!Directory.Exists(dir)) return "";
var candidates = Directory.GetFiles(dir, stem + "*.png");
if (candidates.Length == 0) return "";
// preferir scale-100/125/150, depois qualquer
return candidates.OrderBy(f =>
{
var n = Path.GetFileName(f);
if (n.Contains("scale-100")) return 0;
if (n.Contains("scale-125")) return 1;
if (n.Contains("scale-150")) return 2;
if (n.Contains("targetsize-48")) return 3;
return 9;
}).First();
}
catch { return ""; }
}
public static System.Windows.Media.Imaging.BitmapSource? LoadLogo(AppxPackage p)
{
if (p.LogoPath.Length == 0 || !File.Exists(p.LogoPath)) return null;
try
{
var bmp = new System.Windows.Media.Imaging.BitmapImage();
bmp.BeginInit();
bmp.CacheOption = System.Windows.Media.Imaging.BitmapCacheOption.OnLoad;
bmp.DecodePixelWidth = 48;
bmp.UriSource = new Uri(p.LogoPath);
bmp.EndInit();
bmp.Freeze();
return bmp;
}
catch { return null; }
}
private static async Task<string> RunPsAsync(string script, TimeSpan timeout)
{
var path = Path.Combine(Path.GetTempPath(), $"adams-appx-{Guid.NewGuid():N}.ps1");
try
{
await File.WriteAllTextAsync(path, script, System.Text.Encoding.UTF8);
var psi = new ProcessStartInfo("powershell.exe",
$"-NoProfile -ExecutionPolicy Bypass -NonInteractive -File \"{path}\"")
{
UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true,
CreateNoWindow = true, StandardOutputEncoding = System.Text.Encoding.UTF8,
};
using var proc = Process.Start(psi);
if (proc == null) return "";
var stdout = proc.StandardOutput.ReadToEndAsync();
_ = proc.StandardError.ReadToEndAsync();
using var cts = new CancellationTokenSource(timeout);
try { await proc.WaitForExitAsync(cts.Token); }
catch (OperationCanceledException) { try { proc.Kill(true); } catch { } return ""; }
return await stdout;
}
catch { return ""; }
finally { try { File.Delete(path); } catch { } }
}
}