diff --git a/WSA System Control/AndroidApp.cs b/WSA System Control/AndroidApp.cs new file mode 100644 index 0000000..7955d00 --- /dev/null +++ b/WSA System Control/AndroidApp.cs @@ -0,0 +1,18 @@ +namespace WSA_System_Control +{ + public class AndroidApp + { + public string PackageName { get; set; } + public string AppName { get; set; } + public Image? AppIcon { get; set; } + + public AndroidApp(string packageName, string appName = null, Image? appIcon = null) + { + PackageName = packageName; + AppName = appName ?? packageName; + AppIcon = appIcon; + } + + public override string ToString() => AppName; + } +} diff --git a/WSA System Control/AndroidAppManager.cs b/WSA System Control/AndroidAppManager.cs new file mode 100644 index 0000000..422ae32 --- /dev/null +++ b/WSA System Control/AndroidAppManager.cs @@ -0,0 +1,295 @@ +using System.Diagnostics; +using System.Text.Json; +using System.Text.RegularExpressions; +using System.Drawing; +using System.IO; +using System.Runtime.InteropServices; +using System.Reflection; + +namespace WSA_System_Control +{ + public class AndroidAppManager + { + private const string AdbPort = "58526"; + + private class StartApp + { + public string Name { get; set; } = string.Empty; + public string AppID { get; set; } = string.Empty; + } + + private static List? _cachedApps; + + public static async Task> GetInstalledAppsAsync(bool forceRefresh = false) + { + if (!forceRefresh && _cachedApps != null) + { + return _cachedApps; + } + + var apps = new List(); + + // Ensure ADB is connected to WSA + await RunAdbCommandAsync($"connect 127.0.0.1:{AdbPort}"); + + // Get Windows Start Apps to match names + var startApps = await GetWindowsStartAppsAsync(); + + // Get Shortcuts to extract icons + var shortcuts = GetShortcutPaths(); + + // Get ALL packages to find system apps that might be in Start Menu (like Play Store) + string allPackagesOutput = await RunAdbCommandAsync("shell pm list packages"); + var allPackages = allPackagesOutput.Split(["\r\n", "\r", "\n"], StringSplitOptions.RemoveEmptyEntries) + .Select(l => l.Replace("package:", "").Trim()); + + foreach (var packageName in allPackages) + + { + // Only process packages that follow standard naming (e.g. "com.example.app") + // This generically filters out system processes like "android", "root", etc., + // and prevents false positive matches against Windows apps. + if (!packageName.Contains('.')) continue; + + var match = startApps.FirstOrDefault(a => a.AppID.Contains(packageName, StringComparison.OrdinalIgnoreCase)); + + if (match != null) + { + string appName = match.Name; + + // Avoid duplicates if a package somehow appears twice + if (!apps.Any(a => a.PackageName == packageName)) + { + // Try to find icon + Image? icon = null; + if (shortcuts.TryGetValue(appName, out string shortcutPath)) + { + icon = ExtractIcon(shortcutPath); + } + + apps.Add(new AndroidApp(packageName, appName, icon)); + } + } + } + + _cachedApps = [.. apps.OrderBy(a => a.AppName)]; + return _cachedApps; + } + + private static async Task> GetWindowsStartAppsAsync() + { + try + { + ProcessStartInfo psi = new ProcessStartInfo + { + FileName = "powershell.exe", + Arguments = "-NoProfile -Command \"Get-StartApps | Select-Object Name, AppID | ConvertTo-Json\"", + RedirectStandardOutput = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + using (Process process = new Process { StartInfo = psi }) + { + process.Start(); + string output = await process.StandardOutput.ReadToEndAsync(); + await process.WaitForExitAsync(); + + if (string.IsNullOrEmpty(output)) return new List(); + + return JsonSerializer.Deserialize>(output) ?? new List(); + } + } + catch (Exception ex) + { + Debug.WriteLine($"PowerShell Error: {ex.Message}"); + return new List(); + } + } + + private static string GetPrettyName(string packageName) + { + // Simple logic to make package names look a bit better if they are the only thing we have + var parts = packageName.Split('.'); + if (parts.Length > 0) + { + string lastPart = parts[parts.Length - 1]; + return char.ToUpper(lastPart[0]) + lastPart.Substring(1); + } + return packageName; + } + + private static async Task RunAdbCommandAsync(string arguments) + { + try + { + ProcessStartInfo psi = new ProcessStartInfo + { + FileName = "adb.exe", + Arguments = arguments, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + using (Process process = new Process { StartInfo = psi }) + { + process.Start(); + string output = await process.StandardOutput.ReadToEndAsync(); + await process.WaitForExitAsync(); + return output ?? string.Empty; + } + } + catch (Exception ex) + { + Debug.WriteLine($"ADB Error: {ex.Message}"); + return string.Empty; + } + } + + public static void LaunchApp(string packageName) + { + Process proc = new Process(); + proc.StartInfo.CreateNoWindow = true; + proc.StartInfo.FileName = "WSAClient.exe"; + proc.StartInfo.Arguments = $"/launch wsa://{packageName}"; + proc.Start(); + } + + private static Dictionary GetShortcutPaths() + { + var paths = new Dictionary(StringComparer.OrdinalIgnoreCase); + + var locations = new[] + { + Environment.GetFolderPath(Environment.SpecialFolder.StartMenu), + Environment.GetFolderPath(Environment.SpecialFolder.CommonStartMenu) + }; + + foreach (var location in locations) + { + if (Directory.Exists(location)) + { + try + { + var files = Directory.GetFiles(location, "*.lnk", SearchOption.AllDirectories); + foreach (var file in files) + { + string name = Path.GetFileNameWithoutExtension(file); + if (!paths.ContainsKey(name)) + { + paths[name] = file; + } + } + } + catch (Exception ex) + { + Debug.WriteLine($"Error scanning shortcuts: {ex.Message}"); + } + } + } + return paths; + } + + private static Image? ExtractIcon(string path) + { + try + { + // Try to resolve shortcut target to get clean icon without overlay + Type? shellType = Type.GetTypeFromProgID("WScript.Shell"); + if (shellType != null) + { + dynamic? shell = Activator.CreateInstance(shellType); + if (shell != null) + { + dynamic shortcut = shell.CreateShortcut(path); + string iconLocation = shortcut.IconLocation; + + if (!string.IsNullOrEmpty(iconLocation)) + { + // IconLocation format is usually "Path,Index" + string[] parts = iconLocation.Split(','); + string iconFile = parts[0]; + int iconIndex = 0; + if (parts.Length > 1) + { + int.TryParse(parts[1], out iconIndex); + } + + // Expand environment variables + iconFile = Environment.ExpandEnvironmentVariables(iconFile); + + if (File.Exists(iconFile)) + { + return ExtractIconFromPath(iconFile, iconIndex); + } + } + } + } + } + catch (Exception ex) + { + Debug.WriteLine($"Error resolving shortcut icon: {ex.Message}"); + } + + // Fallback to extraction from shortcut file (may include overlay) + try + { + if (File.Exists(path)) + { + using (var icon = Icon.ExtractAssociatedIcon(path)) + { + return icon?.ToBitmap(); + } + } + } + catch (Exception ex) + { + Debug.WriteLine($"Error extracting icon: {ex.Message}"); + } + return null; + } + + [DllImport("shell32.dll", CharSet = CharSet.Auto)] + private static extern uint ExtractIconEx(string szFileName, int nIconIndex, IntPtr[] phiconLarge, IntPtr[] phiconSmall, uint nIcons); + + [DllImport("user32.dll", EntryPoint = "DestroyIcon", SetLastError = true)] + private static extern bool DestroyIcon(IntPtr hIcon); + + private static Image? ExtractIconFromPath(string path, int index) + { + try + { + IntPtr[] phiconLarge = new IntPtr[1]; + IntPtr[] phiconSmall = new IntPtr[1]; + + // Extract 1 icon + if (ExtractIconEx(path, index, phiconLarge, phiconSmall, 1) > 0) + { + Image? image = null; + if (phiconLarge[0] != IntPtr.Zero) + { + using (var icon = Icon.FromHandle(phiconLarge[0])) + { + image = icon.ToBitmap(); + } + DestroyIcon(phiconLarge[0]); + } + + if (phiconSmall[0] != IntPtr.Zero) + { + DestroyIcon(phiconSmall[0]); + } + + return image; + } + } + catch (Exception ex) + { + Debug.WriteLine($"Error extracting icon from path: {ex.Message}"); + } + return null; + } + } +} diff --git a/WSA System Control/AppContext.cs b/WSA System Control/AppContext.cs index 42263ce..25208ca 100644 --- a/WSA System Control/AppContext.cs +++ b/WSA System Control/AppContext.cs @@ -15,7 +15,10 @@ internal class AppContext : ApplicationContext ContextMenuStrip contextMenu; Icon icon; Icon greyIcon; - ToolStripMenuItem startupMenuItem; + ToolStripMenuItem? startupMenuItem; + ToolStripMenuItem? appsMenuItem; + private bool _keepAppsMenuOpen = false; + public AppContext() { if (IsPackaged()) @@ -43,6 +46,7 @@ public AppContext() ToolStripMenuItem filesMenuItem = new ToolStripMenuItem(rm.GetString("WSAFiles"), Image.FromFile("Icons\\folder.ico"), new EventHandler(wsaFiles)); ToolStripMenuItem wsaMenuItem = new ToolStripMenuItem(rm.GetString("WSASettings"), Image.FromFile("Icons\\icon.ico"), new EventHandler(wsaSettings)); ToolStripMenuItem androidMenuItem = new ToolStripMenuItem(rm.GetString("AndroidSettings"), Image.FromFile("Icons\\settings.ico"), new EventHandler(androidSettings)); + appsMenuItem = new ToolStripMenuItem(rm.GetString("AndroidApps"), Image.FromFile("Icons\\icon.ico")); ToolStripSeparator separator2 = new ToolStripSeparator(); startupMenuItem = new ToolStripMenuItem(rm.GetString("RunStartup"), null, new EventHandler(toggleStartup)); ToolStripMenuItem aboutMenuItem = new ToolStripMenuItem(rm.GetString("About"), Image.FromFile("Icons\\info.ico"), new EventHandler(aboutDialog)); @@ -63,11 +67,12 @@ public AppContext() contextMenu.Items.Add(filesMenuItem); contextMenu.Items.Add(wsaMenuItem); contextMenu.Items.Add(androidMenuItem); + contextMenu.Items.Add(appsMenuItem); contextMenu.Items.Add(separator2); if (IsPackaged()) { contextMenu.Items.Add(startupMenuItem); - contextMenu.Items[7].Enabled = false; + contextMenu.Items[8].Enabled = false; } contextMenu.Items.Add(aboutMenuItem); if (!IsPackaged()) @@ -83,6 +88,25 @@ public AppContext() notifyIcon.ContextMenuStrip = contextMenu; notifyIcon.Visible = true; + appsMenuItem.DropDownOpening += AppsMenuItem_DropDownOpening; + + appsMenuItem.DropDown.ItemClicked += (s, e) => + { + if (e.ClickedItem != null && e.ClickedItem.Tag is string tag && tag == "Refresh") + { + _keepAppsMenuOpen = true; + } + }; + + appsMenuItem.DropDown.Closing += (s, e) => + { + if (_keepAppsMenuOpen && e.CloseReason == ToolStripDropDownCloseReason.ItemClicked) + { + e.Cancel = true; + _keepAppsMenuOpen = false; + } + }; + Thread t = new Thread(new ThreadStart(Monitor)); t.Start(); @@ -90,6 +114,127 @@ public AppContext() } } + private void AppsMenuItem_DropDownOpening(object? sender, EventArgs e) + { + _ = RefreshAppList(false); + } + + private async Task RefreshAppList(bool forceRefresh) + { + if (appsMenuItem == null) return; + + // If forced refresh, we might want to clear previous items, but keep the Refresh button if possible. + // However, easiest way to ensure clean state is to rebuild or update. + // Since we are inside an opened menu, we should be careful about clearing. + + // Let's see if we need to initialize the basic structure (Refresh button) + var refreshText = rm.GetString("Refresh"); + if (string.IsNullOrEmpty(refreshText)) refreshText = "Refresh"; + + ToolStripMenuItem? refreshItem = null; + + // Check if refresh item already exists + foreach (ToolStripItem item in appsMenuItem.DropDownItems) + { + if (item.Tag is string tag && tag == "Refresh") + { + refreshItem = item as ToolStripMenuItem; + break; + } + } + + if (refreshItem == null) + { + // Initialize menu structure + appsMenuItem.DropDownItems.Clear(); + + refreshItem = new ToolStripMenuItem(refreshText, Image.FromFile("Icons\\update.ico")); + refreshItem.Tag = "Refresh"; + refreshItem.BackColor = contextMenu.BackColor; + refreshItem.ForeColor = contextMenu.ForeColor; + refreshItem.Click += async (s, ev) => + { + await RefreshAppList(true); + }; + appsMenuItem.DropDownItems.Add(refreshItem); + appsMenuItem.DropDownItems.Add(new ToolStripSeparator()); + } + + // If we are forcing refresh, we should show loading state below the separator + if (forceRefresh) + { + // Remove existing apps (keep Refresh button and separator) + while(appsMenuItem.DropDownItems.Count > 2) + { + appsMenuItem.DropDownItems.RemoveAt(2); + } + } + + // Add loading indicator if not present and we are about to fetch + // But if forceRefresh is false and we already have apps, we don't need to do anything? + // The method GetInstalledAppsAsync handles cache. + // If cache exists and !forceRefresh, it returns immediately. + + var loadingItem = new ToolStripMenuItem(rm.GetString("Loading")); + loadingItem.BackColor = contextMenu.BackColor; + loadingItem.ForeColor = contextMenu.ForeColor; + + bool addedLoading = false; + // Only add loading if we don't have apps yet or we are forcing + if (appsMenuItem.DropDownItems.Count <= 2) + { + appsMenuItem.DropDownItems.Add(loadingItem); + addedLoading = true; + } + + var apps = await AndroidAppManager.GetInstalledAppsAsync(forceRefresh); + + if (addedLoading) + { + appsMenuItem.DropDownItems.Remove(loadingItem); + } + + // Re-populate only if we cleared them or if it's initial load + // If forceRefresh was true, we cleared them above. + // If !forceRefresh, and count <= 2, we need to populate. + // If !forceRefresh and count > 2, we assume they are already there (but maybe we should verify?) + // For simplicity, let's clear and re-add if we fetched new list or if logic requires it. + + // Let's just clear everything after the separator and re-add to be safe and consistent + while(appsMenuItem.DropDownItems.Count > 2) + { + appsMenuItem.DropDownItems.RemoveAt(2); + } + + if (apps.Count == 0) + { + var noAppsItem = new ToolStripMenuItem("No apps found") { Enabled = false }; + noAppsItem.BackColor = contextMenu.BackColor; + noAppsItem.ForeColor = contextMenu.ForeColor; + appsMenuItem.DropDownItems.Add(noAppsItem); + } + else + { + foreach (var app in apps) + { + var item = new ToolStripMenuItem(app.AppName); + item.Tag = app.PackageName; + item.Click += (s, ev) => AndroidAppManager.LaunchApp(app.PackageName); + item.BackColor = contextMenu.BackColor; + item.ForeColor = contextMenu.ForeColor; + if (app.AppIcon != null) + { + item.Image = app.AppIcon; + } + else + { + item.Image = Image.FromFile("Icons\\icon.ico"); + } + appsMenuItem.DropDownItems.Add(item); + } + } + } + internal static bool IsPackaged() { try @@ -107,14 +252,31 @@ internal static bool IsPackaged() private void setTheme() { int res = (int)Registry.GetValue("HKEY_CURRENT_USER\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize", "AppsUseLightTheme", -1); + Color backColor; + Color foreColor; if (res == 0) { - contextMenu.BackColor = ColorTranslator.FromHtml("#FF2D2D30"); - contextMenu.ForeColor = Color.White; - } else + backColor = ColorTranslator.FromHtml("#FF2D2D30"); + foreColor = Color.White; + } + else { - contextMenu.BackColor = Color.White; - contextMenu.ForeColor = Color.Black; + backColor = Color.White; + foreColor = Color.Black; + } + + contextMenu.BackColor = backColor; + contextMenu.ForeColor = foreColor; + + foreach (ToolStripItem item in contextMenu.Items) + { + item.BackColor = backColor; + item.ForeColor = foreColor; + if (item is ToolStripMenuItem menuItem) + { + menuItem.DropDown.BackColor = backColor; + menuItem.DropDown.ForeColor = foreColor; + } } } @@ -286,7 +448,7 @@ private async void startMenuState() { contextMenu.Invoke(() => { - startupMenuItem.Checked = true; + if (startupMenuItem != null) startupMenuItem.Checked = true; }); contextMenu.Items[7].Enabled = true; @@ -295,7 +457,7 @@ private async void startMenuState() { contextMenu.Invoke(() => { - startupMenuItem.Checked = false; + if (startupMenuItem != null) startupMenuItem.Checked = false; }); contextMenu.Items[7].Enabled = true; @@ -307,14 +469,14 @@ private async void startMenuState() { contextMenu.Invoke(() => { - startupMenuItem.Checked = true; + if (startupMenuItem != null) startupMenuItem.Checked = true; }); } else { contextMenu.Invoke(() => { - startupMenuItem.Checked = false; + if (startupMenuItem != null) startupMenuItem.Checked = false; }); } } @@ -330,6 +492,7 @@ void Monitor() { contextMenu.Items[0].Enabled = true; contextMenu.Items[1].Enabled = false; + if (appsMenuItem != null) appsMenuItem.Enabled = false; notifyIcon.Icon = greyIcon; notifyIcon.Text = rm.GetString("WSAOffIcon"); @@ -338,6 +501,7 @@ void Monitor() { contextMenu.Items[0].Enabled = false; contextMenu.Items[1].Enabled = true; + if (appsMenuItem != null) appsMenuItem.Enabled = true; notifyIcon.Icon = icon; notifyIcon.Text = rm.GetString("WSAOnIcon"); } diff --git a/WSA System Control/Resources/Strings.resx b/WSA System Control/Resources/Strings.resx index 8739341..495164d 100644 --- a/WSA System Control/Resources/Strings.resx +++ b/WSA System Control/Resources/Strings.resx @@ -180,4 +180,10 @@ Click for more options WSA Settings + + Android Apps + + + Loading... + \ No newline at end of file