├── .gitattributes ├── .github └── FUNDING.yml ├── .gitignore ├── AppGroup.sln ├── AppGroup ├── App.xaml ├── App.xaml.cs ├── AppGroup.csproj ├── AppGroup.ico ├── AppGroup.png ├── Assets │ ├── LockScreenLogo.scale-200.png │ ├── SplashScreen.scale-200.png │ ├── Square150x150Logo.scale-200.png │ ├── Square44x44Logo.scale-200.png │ ├── Square44x44Logo.targetsize-24_altform-unplated.png │ ├── StoreLogo.png │ ├── Wide310x150Logo.scale-200.png │ ├── coffee_dark.png │ ├── coffee_light.png │ ├── github_dark.png │ └── github_light.png ├── BackupHelper.cs ├── EditGroup.ico ├── EditGroupHelper.cs ├── EditGroupWindow.xaml ├── EditGroupWindow.xaml.cs ├── IconCache.cs ├── IconHelper.cs ├── JsonConfigHelper.cs ├── MainWindow.xaml ├── MainWindow.xaml.cs ├── NativeMethods.cs ├── Package.appxmanifest ├── PopupWindow.xaml ├── PopupWindow.xaml.cs ├── Properties │ └── launchSettings.json ├── SupportDialogHelper.cs ├── ThemeHelper.cs ├── WindowHelper.cs ├── app.manifest └── default_preview.png ├── AppGroupBackground ├── AppGroupBackground.csproj ├── NativeMethods.cs └── Program.cs ├── LICENSE └── README.md /.gitattributes: -------------------------------------------------------------------------------- 1 | ############################################################################### 2 | # Set default behavior to automatically normalize line endings. 3 | ############################################################################### 4 | * text=auto 5 | 6 | ############################################################################### 7 | # Set default behavior for command prompt diff. 8 | # 9 | # This is need for earlier builds of msysgit that does not have it on by 10 | # default for csharp files. 11 | # Note: This is only used by command line 12 | ############################################################################### 13 | #*.cs diff=csharp 14 | 15 | ############################################################################### 16 | # Set the merge driver for project and solution files 17 | # 18 | # Merging from the command prompt will add diff markers to the files if there 19 | # are conflicts (Merging from VS is not affected by the settings below, in VS 20 | # the diff markers are never inserted). Diff markers may cause the following 21 | # file extensions to fail to load in VS. An alternative would be to treat 22 | # these files as binary and thus will always conflict and require user 23 | # intervention with every merge. To do so, just uncomment the entries below 24 | ############################################################################### 25 | #*.sln merge=binary 26 | #*.csproj merge=binary 27 | #*.vbproj merge=binary 28 | #*.vcxproj merge=binary 29 | #*.vcproj merge=binary 30 | #*.dbproj merge=binary 31 | #*.fsproj merge=binary 32 | #*.lsproj merge=binary 33 | #*.wixproj merge=binary 34 | #*.modelproj merge=binary 35 | #*.sqlproj merge=binary 36 | #*.wwaproj merge=binary 37 | 38 | ############################################################################### 39 | # behavior for image files 40 | # 41 | # image files are treated as binary by default. 42 | ############################################################################### 43 | #*.jpg binary 44 | #*.png binary 45 | #*.gif binary 46 | 47 | ############################################################################### 48 | # diff behavior for common document formats 49 | # 50 | # Convert binary document formats to text before diffing them. This feature 51 | # is only available from the command line. Turn it on by uncommenting the 52 | # entries below. 53 | ############################################################################### 54 | #*.doc diff=astextplain 55 | #*.DOC diff=astextplain 56 | #*.docx diff=astextplain 57 | #*.DOCX diff=astextplain 58 | #*.dot diff=astextplain 59 | #*.DOT diff=astextplain 60 | #*.pdf diff=astextplain 61 | #*.PDF diff=astextplain 62 | #*.rtf diff=astextplain 63 | #*.RTF diff=astextplain 64 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: iandiv 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 12 | polar: # Replace with a single Polar username 13 | buy_me_a_coffee: # Replace with a single Buy Me a Coffee username 14 | thanks_dev: # Replace with a single thanks.dev username 15 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## Ignore Visual Studio temporary files, build results, and 2 | ## files generated by popular Visual Studio add-ons. 3 | ## 4 | ## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore 5 | 6 | # User-specific files 7 | *.rsuser 8 | *.suo 9 | *.user 10 | *.userosscache 11 | *.sln.docstates 12 | 13 | # User-specific files (MonoDevelop/Xamarin Studio) 14 | *.userprefs 15 | 16 | # Mono auto generated files 17 | mono_crash.* 18 | 19 | # Build results 20 | [Dd]ebug/ 21 | [Dd]ebugPublic/ 22 | [Rr]elease/ 23 | [Rr]eleases/ 24 | x64/ 25 | x86/ 26 | [Ww][Ii][Nn]32/ 27 | [Aa][Rr][Mm]/ 28 | [Aa][Rr][Mm]64/ 29 | bld/ 30 | [Bb]in/ 31 | [Oo]bj/ 32 | [Oo]ut/ 33 | [Ll]og/ 34 | [Ll]ogs/ 35 | 36 | # Visual Studio 2015/2017 cache/options directory 37 | .vs/ 38 | # Uncomment if you have tasks that create the project's static files in wwwroot 39 | #wwwroot/ 40 | 41 | # Visual Studio 2017 auto generated files 42 | Generated\ Files/ 43 | 44 | # MSTest test Results 45 | [Tt]est[Rr]esult*/ 46 | [Bb]uild[Ll]og.* 47 | 48 | # NUnit 49 | *.VisualState.xml 50 | TestResult.xml 51 | nunit-*.xml 52 | 53 | # Build Results of an ATL Project 54 | [Dd]ebugPS/ 55 | [Rr]eleasePS/ 56 | dlldata.c 57 | 58 | # Benchmark Results 59 | BenchmarkDotNet.Artifacts/ 60 | 61 | # .NET Core 62 | project.lock.json 63 | project.fragment.lock.json 64 | artifacts/ 65 | 66 | # ASP.NET Scaffolding 67 | ScaffoldingReadMe.txt 68 | 69 | # StyleCop 70 | StyleCopReport.xml 71 | 72 | # Files built by Visual Studio 73 | *_i.c 74 | *_p.c 75 | *_h.h 76 | *.ilk 77 | *.meta 78 | *.obj 79 | *.iobj 80 | *.pch 81 | *.pdb 82 | *.ipdb 83 | *.pgc 84 | *.pgd 85 | *.rsp 86 | *.sbr 87 | *.tlb 88 | *.tli 89 | *.tlh 90 | *.tmp 91 | *.tmp_proj 92 | *_wpftmp.csproj 93 | *.log 94 | *.vspscc 95 | *.vssscc 96 | .builds 97 | *.pidb 98 | *.svclog 99 | *.scc 100 | 101 | # Chutzpah Test files 102 | _Chutzpah* 103 | 104 | # Visual C++ cache files 105 | ipch/ 106 | *.aps 107 | *.ncb 108 | *.opendb 109 | *.opensdf 110 | *.sdf 111 | *.cachefile 112 | *.VC.db 113 | *.VC.VC.opendb 114 | 115 | # Visual Studio profiler 116 | *.psess 117 | *.vsp 118 | *.vspx 119 | *.sap 120 | 121 | # Visual Studio Trace Files 122 | *.e2e 123 | 124 | # TFS 2012 Local Workspace 125 | $tf/ 126 | 127 | # Guidance Automation Toolkit 128 | *.gpState 129 | 130 | # ReSharper is a .NET coding add-in 131 | _ReSharper*/ 132 | *.[Rr]e[Ss]harper 133 | *.DotSettings.user 134 | 135 | # TeamCity is a build add-in 136 | _TeamCity* 137 | 138 | # DotCover is a Code Coverage Tool 139 | *.dotCover 140 | 141 | # AxoCover is a Code Coverage Tool 142 | .axoCover/* 143 | !.axoCover/settings.json 144 | 145 | # Coverlet is a free, cross platform Code Coverage Tool 146 | coverage*.json 147 | coverage*.xml 148 | coverage*.info 149 | 150 | # Visual Studio code coverage results 151 | *.coverage 152 | *.coveragexml 153 | 154 | # NCrunch 155 | _NCrunch_* 156 | .*crunch*.local.xml 157 | nCrunchTemp_* 158 | 159 | # MightyMoose 160 | *.mm.* 161 | AutoTest.Net/ 162 | 163 | # Web workbench (sass) 164 | .sass-cache/ 165 | 166 | # Installshield output folder 167 | [Ee]xpress/ 168 | 169 | # DocProject is a documentation generator add-in 170 | DocProject/buildhelp/ 171 | DocProject/Help/*.HxT 172 | DocProject/Help/*.HxC 173 | DocProject/Help/*.hhc 174 | DocProject/Help/*.hhk 175 | DocProject/Help/*.hhp 176 | DocProject/Help/Html2 177 | DocProject/Help/html 178 | 179 | # Click-Once directory 180 | publish/ 181 | 182 | # Publish Web Output 183 | *.[Pp]ublish.xml 184 | *.azurePubxml 185 | # Note: Comment the next line if you want to checkin your web deploy settings, 186 | # but database connection strings (with potential passwords) will be unencrypted 187 | *.pubxml 188 | *.publishproj 189 | 190 | # Microsoft Azure Web App publish settings. Comment the next line if you want to 191 | # checkin your Azure Web App publish settings, but sensitive information contained 192 | # in these scripts will be unencrypted 193 | PublishScripts/ 194 | 195 | # NuGet Packages 196 | *.nupkg 197 | # NuGet Symbol Packages 198 | *.snupkg 199 | # The packages folder can be ignored because of Package Restore 200 | **/[Pp]ackages/* 201 | # except build/, which is used as an MSBuild target. 202 | !**/[Pp]ackages/build/ 203 | # Uncomment if necessary however generally it will be regenerated when needed 204 | #!**/[Pp]ackages/repositories.config 205 | # NuGet v3's project.json files produces more ignorable files 206 | *.nuget.props 207 | *.nuget.targets 208 | 209 | # Microsoft Azure Build Output 210 | csx/ 211 | *.build.csdef 212 | 213 | # Microsoft Azure Emulator 214 | ecf/ 215 | rcf/ 216 | 217 | # Windows Store app package directories and files 218 | AppPackages/ 219 | BundleArtifacts/ 220 | Package.StoreAssociation.xml 221 | _pkginfo.txt 222 | *.appx 223 | *.appxbundle 224 | *.appxupload 225 | 226 | # Visual Studio cache files 227 | # files ending in .cache can be ignored 228 | *.[Cc]ache 229 | # but keep track of directories ending in .cache 230 | !?*.[Cc]ache/ 231 | 232 | # Others 233 | ClientBin/ 234 | ~$* 235 | *~ 236 | *.dbmdl 237 | *.dbproj.schemaview 238 | *.jfm 239 | *.pfx 240 | *.publishsettings 241 | orleans.codegen.cs 242 | 243 | # Including strong name files can present a security risk 244 | # (https://github.com/github/gitignore/pull/2483#issue-259490424) 245 | #*.snk 246 | 247 | # Since there are multiple workflows, uncomment next line to ignore bower_components 248 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) 249 | #bower_components/ 250 | 251 | # RIA/Silverlight projects 252 | Generated_Code/ 253 | 254 | # Backup & report files from converting an old project file 255 | # to a newer Visual Studio version. Backup files are not needed, 256 | # because we have git ;-) 257 | _UpgradeReport_Files/ 258 | Backup*/ 259 | UpgradeLog*.XML 260 | UpgradeLog*.htm 261 | ServiceFabricBackup/ 262 | *.rptproj.bak 263 | 264 | # SQL Server files 265 | *.mdf 266 | *.ldf 267 | *.ndf 268 | 269 | # Business Intelligence projects 270 | *.rdl.data 271 | *.bim.layout 272 | *.bim_*.settings 273 | *.rptproj.rsuser 274 | *- [Bb]ackup.rdl 275 | *- [Bb]ackup ([0-9]).rdl 276 | *- [Bb]ackup ([0-9][0-9]).rdl 277 | 278 | # Microsoft Fakes 279 | FakesAssemblies/ 280 | 281 | # GhostDoc plugin setting file 282 | *.GhostDoc.xml 283 | 284 | # Node.js Tools for Visual Studio 285 | .ntvs_analysis.dat 286 | node_modules/ 287 | 288 | # Visual Studio 6 build log 289 | *.plg 290 | 291 | # Visual Studio 6 workspace options file 292 | *.opt 293 | 294 | # Visual Studio 6 auto-generated workspace file (contains which files were open etc.) 295 | *.vbw 296 | 297 | # Visual Studio LightSwitch build output 298 | **/*.HTMLClient/GeneratedArtifacts 299 | **/*.DesktopClient/GeneratedArtifacts 300 | **/*.DesktopClient/ModelManifest.xml 301 | **/*.Server/GeneratedArtifacts 302 | **/*.Server/ModelManifest.xml 303 | _Pvt_Extensions 304 | 305 | # Paket dependency manager 306 | .paket/paket.exe 307 | paket-files/ 308 | 309 | # FAKE - F# Make 310 | .fake/ 311 | 312 | # CodeRush personal settings 313 | .cr/personal 314 | 315 | # Python Tools for Visual Studio (PTVS) 316 | __pycache__/ 317 | *.pyc 318 | 319 | # Cake - Uncomment if you are using it 320 | # tools/** 321 | # !tools/packages.config 322 | 323 | # Tabs Studio 324 | *.tss 325 | 326 | # Telerik's JustMock configuration file 327 | *.jmconfig 328 | 329 | # BizTalk build output 330 | *.btp.cs 331 | *.btm.cs 332 | *.odx.cs 333 | *.xsd.cs 334 | 335 | # OpenCover UI analysis results 336 | OpenCover/ 337 | 338 | # Azure Stream Analytics local run output 339 | ASALocalRun/ 340 | 341 | # MSBuild Binary and Structured Log 342 | *.binlog 343 | 344 | # NVidia Nsight GPU debugger configuration file 345 | *.nvuser 346 | 347 | # MFractors (Xamarin productivity tool) working folder 348 | .mfractor/ 349 | 350 | # Local History for Visual Studio 351 | .localhistory/ 352 | 353 | # BeatPulse healthcheck temp database 354 | healthchecksdb 355 | 356 | # Backup folder for Package Reference Convert tool in Visual Studio 2017 357 | MigrationBackup/ 358 | 359 | # Ionide (cross platform F# VS Code tools) working folder 360 | .ionide/ 361 | 362 | # Fody - auto-generated XML schema 363 | FodyWeavers.xsd -------------------------------------------------------------------------------- /AppGroup.sln: -------------------------------------------------------------------------------- 1 |  2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio Version 17 4 | VisualStudioVersion = 17.13.35806.99 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AppGroup", "AppGroup\AppGroup.csproj", "{6F1A69BC-EA40-4AC3-8452-9C3D1E3C989B}" 7 | EndProject 8 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AppGroupBackground", "AppGroupBackground\AppGroupBackground.csproj", "{2F515274-5D0B-4E75-8273-335AFD3E8374}" 9 | EndProject 10 | Global 11 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 12 | Debug|ARM64 = Debug|ARM64 13 | Debug|x64 = Debug|x64 14 | Debug|x86 = Debug|x86 15 | Release|ARM64 = Release|ARM64 16 | Release|x64 = Release|x64 17 | Release|x86 = Release|x86 18 | EndGlobalSection 19 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 20 | {6F1A69BC-EA40-4AC3-8452-9C3D1E3C989B}.Debug|ARM64.ActiveCfg = Debug|ARM64 21 | {6F1A69BC-EA40-4AC3-8452-9C3D1E3C989B}.Debug|ARM64.Build.0 = Debug|ARM64 22 | {6F1A69BC-EA40-4AC3-8452-9C3D1E3C989B}.Debug|ARM64.Deploy.0 = Debug|ARM64 23 | {6F1A69BC-EA40-4AC3-8452-9C3D1E3C989B}.Debug|x64.ActiveCfg = Debug|x64 24 | {6F1A69BC-EA40-4AC3-8452-9C3D1E3C989B}.Debug|x64.Build.0 = Debug|x64 25 | {6F1A69BC-EA40-4AC3-8452-9C3D1E3C989B}.Debug|x64.Deploy.0 = Debug|x64 26 | {6F1A69BC-EA40-4AC3-8452-9C3D1E3C989B}.Debug|x86.ActiveCfg = Debug|x86 27 | {6F1A69BC-EA40-4AC3-8452-9C3D1E3C989B}.Debug|x86.Build.0 = Debug|x86 28 | {6F1A69BC-EA40-4AC3-8452-9C3D1E3C989B}.Debug|x86.Deploy.0 = Debug|x86 29 | {6F1A69BC-EA40-4AC3-8452-9C3D1E3C989B}.Release|ARM64.ActiveCfg = Release|ARM64 30 | {6F1A69BC-EA40-4AC3-8452-9C3D1E3C989B}.Release|ARM64.Build.0 = Release|ARM64 31 | {6F1A69BC-EA40-4AC3-8452-9C3D1E3C989B}.Release|ARM64.Deploy.0 = Release|ARM64 32 | {6F1A69BC-EA40-4AC3-8452-9C3D1E3C989B}.Release|x64.ActiveCfg = Release|x64 33 | {6F1A69BC-EA40-4AC3-8452-9C3D1E3C989B}.Release|x64.Build.0 = Release|x64 34 | {6F1A69BC-EA40-4AC3-8452-9C3D1E3C989B}.Release|x64.Deploy.0 = Release|x64 35 | {6F1A69BC-EA40-4AC3-8452-9C3D1E3C989B}.Release|x86.ActiveCfg = Release|x86 36 | {6F1A69BC-EA40-4AC3-8452-9C3D1E3C989B}.Release|x86.Build.0 = Release|x86 37 | {6F1A69BC-EA40-4AC3-8452-9C3D1E3C989B}.Release|x86.Deploy.0 = Release|x86 38 | {2F515274-5D0B-4E75-8273-335AFD3E8374}.Debug|ARM64.ActiveCfg = Debug|Any CPU 39 | {2F515274-5D0B-4E75-8273-335AFD3E8374}.Debug|ARM64.Build.0 = Debug|Any CPU 40 | {2F515274-5D0B-4E75-8273-335AFD3E8374}.Debug|x64.ActiveCfg = Debug|Any CPU 41 | {2F515274-5D0B-4E75-8273-335AFD3E8374}.Debug|x64.Build.0 = Debug|Any CPU 42 | {2F515274-5D0B-4E75-8273-335AFD3E8374}.Debug|x86.ActiveCfg = Debug|Any CPU 43 | {2F515274-5D0B-4E75-8273-335AFD3E8374}.Debug|x86.Build.0 = Debug|Any CPU 44 | {2F515274-5D0B-4E75-8273-335AFD3E8374}.Release|ARM64.ActiveCfg = Release|Any CPU 45 | {2F515274-5D0B-4E75-8273-335AFD3E8374}.Release|ARM64.Build.0 = Release|Any CPU 46 | {2F515274-5D0B-4E75-8273-335AFD3E8374}.Release|x64.ActiveCfg = Release|Any CPU 47 | {2F515274-5D0B-4E75-8273-335AFD3E8374}.Release|x64.Build.0 = Release|Any CPU 48 | {2F515274-5D0B-4E75-8273-335AFD3E8374}.Release|x86.ActiveCfg = Release|Any CPU 49 | {2F515274-5D0B-4E75-8273-335AFD3E8374}.Release|x86.Build.0 = Release|Any CPU 50 | EndGlobalSection 51 | GlobalSection(SolutionProperties) = preSolution 52 | HideSolutionNode = FALSE 53 | EndGlobalSection 54 | GlobalSection(ExtensibilityGlobals) = postSolution 55 | SolutionGuid = {9D4B26D3-9C1B-4EB2-A9CE-7343059801DF} 56 | EndGlobalSection 57 | EndGlobal 58 | -------------------------------------------------------------------------------- /AppGroup/App.xaml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | -------------------------------------------------------------------------------- /AppGroup/App.xaml.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Diagnostics; 3 | using System.IO; 4 | using System.Linq; 5 | using System.Runtime.InteropServices; 6 | using System.Text.Json; 7 | using System.Text.RegularExpressions; 8 | using System.Threading; 9 | using System.Threading.Tasks; 10 | using Microsoft.UI.Xaml; 11 | using Windows.UI.StartScreen; 12 | using WinRT.Interop; 13 | using WinUIEx; 14 | 15 | namespace AppGroup { 16 | 17 | public partial class App : Application { 18 | 19 | public App() { 20 | 21 | string[] cmdArgs = Environment.GetCommandLineArgs(); 22 | 23 | _ = Task.Run(() => EnsureBackgroundClientRunning()); 24 | if (cmdArgs.Length > 1) { 25 | string groupName = cmdArgs[1]; 26 | 27 | if (groupName != "EditGroupWindow" && groupName != "LaunchAll") { 28 | // Check if window for this group already exists 29 | IntPtr hWnd = NativeMethods.FindWindow(null, groupName); 30 | if (hWnd != IntPtr.Zero) { 31 | 32 | NativeMethods.SetForegroundWindow(hWnd); 33 | NativeMethods.ShowWindow(hWnd, NativeMethods.SW_RESTORE); 34 | NativeMethods.PositionWindowAboveTaskbar(hWnd); 35 | Environment.Exit(0); 36 | } 37 | // Check if Group Name exist in JSON 38 | if (!JsonConfigHelper.GroupExistsInJson(groupName)) { 39 | Environment.Exit(0); 40 | } 41 | } 42 | } 43 | 44 | InitializeJumpListAsync(); 45 | this.InitializeComponent(); 46 | } 47 | 48 | // Method to create a Jump List Item 49 | 50 | 51 | 52 | private async Task InitializeJumpListAsync() { 53 | var jumpListItem = CreateJumpListItemTask(); 54 | var launchAllItem = CreateLaunchAllJumpListItem(); 55 | 56 | JumpList jumpList = await JumpList.LoadCurrentAsync(); 57 | jumpList.Items.Clear(); 58 | jumpList.Items.Add(jumpListItem); 59 | jumpList.Items.Add(launchAllItem); 60 | 61 | await jumpList.SaveAsync(); 62 | } 63 | 64 | // Method to create a Jump List Item for launching all paths in a group 65 | private JumpListItem CreateLaunchAllJumpListItem() { 66 | string groupName = Environment.GetCommandLineArgs()[1]; 67 | var taskItem = JumpListItem.CreateWithArguments($"LaunchAll --groupName=\"{groupName}\"", "Launch All"); 68 | return taskItem; 69 | } 70 | private JumpListItem CreateJumpListItemTask() { 71 | int groupId = JsonConfigHelper.FindKeyByGroupName(Environment.GetCommandLineArgs()[1]); 72 | var taskItem = JumpListItem.CreateWithArguments("EditGroupWindow --id=" + groupId, "Edit this Group "); 73 | return taskItem; 74 | } 75 | protected async override void OnLaunched(Microsoft.UI.Xaml.LaunchActivatedEventArgs args) { 76 | try { 77 | string[] cmdArgs = Environment.GetCommandLineArgs(); 78 | if (cmdArgs.Length > 1) { 79 | string groupName = cmdArgs[1]; 80 | bool isSilent = cmdArgs.Contains("--silent"); 81 | 82 | if (groupName == "EditGroupWindow") { 83 | int id = ExtractIdFromCommandLine(cmdArgs); 84 | 85 | EditGroupWindow editGroupWindow = new EditGroupWindow(id); 86 | editGroupWindow.Activate(); 87 | } 88 | else if (groupName == "LaunchAll") { 89 | string targetGroupName = ExtractGroupNameFromCommandLine(cmdArgs); 90 | await JsonConfigHelper.LaunchAll(targetGroupName); 91 | Environment.Exit(0); 92 | } 93 | else { 94 | popupWindow = new PopupWindow(groupName); 95 | IntPtr hWnd = WindowNative.GetWindowHandle(popupWindow); 96 | popupWindow.InitializeComponent(); 97 | GC.Collect(GC.MaxGeneration, GCCollectionMode.Forced, true, true); 98 | NativeMethods.EmptyWorkingSet(Process.GetCurrentProcess().Handle); 99 | 100 | 101 | if (!isSilent) { 102 | NativeMethods.SetForegroundWindow(hWnd); 103 | NativeMethods.ShowWindow(hWnd, NativeMethods.SW_RESTORE); 104 | NativeMethods.PositionWindowAboveTaskbar(hWnd); 105 | popupWindow.Activate(); 106 | } 107 | } 108 | } 109 | else { 110 | m_window = new MainWindow(); 111 | m_window.Activate(); 112 | } 113 | } 114 | catch (Exception ex) { 115 | Console.WriteLine($"An error occurred: {ex.Message}"); 116 | } 117 | } 118 | private string ExtractGroupNameFromCommandLine(string[] args) { 119 | foreach (string arg in args) { 120 | if (arg.StartsWith("--groupName=")) { 121 | return arg.Substring(12); 122 | } 123 | } 124 | return string.Empty; 125 | } 126 | private int ExtractIdFromCommandLine(string[] args) { 127 | foreach (string arg in args) { 128 | if (arg.StartsWith("--id=")) { 129 | string idStr = arg.Substring(5); 130 | if (int.TryParse(idStr, out int id)) { 131 | return id; 132 | } 133 | } 134 | } 135 | // Return default value if ID not found or invalid 136 | return JsonConfigHelper.GetNextGroupId(); 137 | } 138 | 139 | 140 | private void EnsureBackgroundClientRunning() { 141 | try { 142 | // Check if the mutex exists (indicating the background client is running) 143 | bool mutexExists = false; 144 | using (Mutex mutex = new Mutex(false, "AppGroupBackgroundClientMutex", out mutexExists)) { 145 | if (mutexExists) { 146 | string backgroundClientPath = Path.Combine( 147 | AppDomain.CurrentDomain.BaseDirectory, 148 | "AppGroupBackground.exe"); 149 | //string backgroundClientPath = "C:\\Users\\Ian Divinagracia\\source\\repos\\AppGroup\\AppGroupBackground\\bin\\Debug\\net8.0\\AppGroupBackground.exe"; 150 | if (File.Exists(backgroundClientPath)) { 151 | // Start the background client process 152 | ProcessStartInfo startInfo = new ProcessStartInfo { 153 | FileName = backgroundClientPath, 154 | UseShellExecute = true, 155 | CreateNoWindow = true, 156 | WindowStyle = ProcessWindowStyle.Hidden 157 | }; 158 | 159 | Process.Start(startInfo); 160 | Debug.WriteLine("Started BackgroundClient process"); 161 | } 162 | else { 163 | Debug.WriteLine($"BackgroundClient executable not found at: {backgroundClientPath}"); 164 | } 165 | } 166 | else { 167 | Debug.WriteLine("BackgroundClient is already running"); 168 | } 169 | } 170 | } 171 | catch (Exception ex) { 172 | Debug.WriteLine($"Error checking/starting BackgroundClient: {ex.Message}"); 173 | } 174 | } 175 | 176 | private Window? m_window; 177 | private PopupWindow? popupWindow; 178 | } 179 | } 180 | -------------------------------------------------------------------------------- /AppGroup/AppGroup.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | WinExe 4 | net8.0-windows10.0.19041.0 5 | 10.0.17763.0 6 | AppGroup 7 | app.manifest 8 | x86;x64;ARM64 9 | win-x86;win-x64;win-arm64 10 | win-$(Platform).pubxml 11 | true 12 | true 13 | None 14 | enable 15 | true 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | PreserveNewest 38 | PreserveNewest 39 | 40 | 41 | PreserveNewest 42 | PreserveNewest 43 | 44 | 45 | PreserveNewest 46 | PreserveNewest 47 | 48 | 49 | PreserveNewest 50 | PreserveNewest 51 | 52 | 53 | PreserveNewest 54 | PreserveNewest 55 | 56 | 57 | PreserveNewest 58 | PreserveNewest 59 | 60 | 61 | PreserveNewest 62 | PreserveNewest 63 | 64 | 65 | PreserveNewest 66 | PreserveNewest 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | true 87 | 88 | 89 | true 90 | 91 | 92 | 93 | 94 | 95 | MSBuild:Compile 96 | 97 | 98 | 99 | 100 | MSBuild:Compile 101 | 102 | 103 | 104 | 105 | MSBuild:Compile 106 | 107 | 108 | 109 | 114 | 115 | true 116 | 117 | 118 | 119 | 120 | False 121 | True 122 | False 123 | False 124 | AppGroup.ico 125 | true 126 | 127 | -------------------------------------------------------------------------------- /AppGroup/AppGroup.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iandiv/AppGroup/ca470b5283a54cd02ba7c1a54a79b72c87034c05/AppGroup/AppGroup.ico -------------------------------------------------------------------------------- /AppGroup/AppGroup.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iandiv/AppGroup/ca470b5283a54cd02ba7c1a54a79b72c87034c05/AppGroup/AppGroup.png -------------------------------------------------------------------------------- /AppGroup/Assets/LockScreenLogo.scale-200.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iandiv/AppGroup/ca470b5283a54cd02ba7c1a54a79b72c87034c05/AppGroup/Assets/LockScreenLogo.scale-200.png -------------------------------------------------------------------------------- /AppGroup/Assets/SplashScreen.scale-200.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iandiv/AppGroup/ca470b5283a54cd02ba7c1a54a79b72c87034c05/AppGroup/Assets/SplashScreen.scale-200.png -------------------------------------------------------------------------------- /AppGroup/Assets/Square150x150Logo.scale-200.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iandiv/AppGroup/ca470b5283a54cd02ba7c1a54a79b72c87034c05/AppGroup/Assets/Square150x150Logo.scale-200.png -------------------------------------------------------------------------------- /AppGroup/Assets/Square44x44Logo.scale-200.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iandiv/AppGroup/ca470b5283a54cd02ba7c1a54a79b72c87034c05/AppGroup/Assets/Square44x44Logo.scale-200.png -------------------------------------------------------------------------------- /AppGroup/Assets/Square44x44Logo.targetsize-24_altform-unplated.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iandiv/AppGroup/ca470b5283a54cd02ba7c1a54a79b72c87034c05/AppGroup/Assets/Square44x44Logo.targetsize-24_altform-unplated.png -------------------------------------------------------------------------------- /AppGroup/Assets/StoreLogo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iandiv/AppGroup/ca470b5283a54cd02ba7c1a54a79b72c87034c05/AppGroup/Assets/StoreLogo.png -------------------------------------------------------------------------------- /AppGroup/Assets/Wide310x150Logo.scale-200.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iandiv/AppGroup/ca470b5283a54cd02ba7c1a54a79b72c87034c05/AppGroup/Assets/Wide310x150Logo.scale-200.png -------------------------------------------------------------------------------- /AppGroup/Assets/coffee_dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iandiv/AppGroup/ca470b5283a54cd02ba7c1a54a79b72c87034c05/AppGroup/Assets/coffee_dark.png -------------------------------------------------------------------------------- /AppGroup/Assets/coffee_light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iandiv/AppGroup/ca470b5283a54cd02ba7c1a54a79b72c87034c05/AppGroup/Assets/coffee_light.png -------------------------------------------------------------------------------- /AppGroup/Assets/github_dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iandiv/AppGroup/ca470b5283a54cd02ba7c1a54a79b72c87034c05/AppGroup/Assets/github_dark.png -------------------------------------------------------------------------------- /AppGroup/Assets/github_light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iandiv/AppGroup/ca470b5283a54cd02ba7c1a54a79b72c87034c05/AppGroup/Assets/github_light.png -------------------------------------------------------------------------------- /AppGroup/EditGroup.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iandiv/AppGroup/ca470b5283a54cd02ba7c1a54a79b72c87034c05/AppGroup/EditGroup.ico -------------------------------------------------------------------------------- /AppGroup/EditGroupHelper.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.UI.Windowing; 2 | using System; 3 | using System.Diagnostics; 4 | using System.IO; 5 | using System.Runtime.InteropServices; 6 | using System.Threading.Tasks; 7 | using System.Text; 8 | using Microsoft.UI; 9 | using Windows.UI.WindowManagement; 10 | using Microsoft.UI.Xaml; 11 | 12 | namespace AppGroup { 13 | public class EditGroupHelper { 14 | private readonly string windowTitle; 15 | private readonly int groupId; 16 | private readonly string groupIdFilePath; 17 | private readonly string logFilePath; 18 | 19 | 20 | 21 | public EditGroupHelper(string windowTitle, int groupId) { 22 | this.windowTitle = windowTitle; 23 | this.groupId = groupId; 24 | // Define the local application data path 25 | string localAppDataPath = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); 26 | string appDataPath = Path.Combine(localAppDataPath, "AppGroup"); 27 | 28 | // Ensure the directory exists 29 | if (!Directory.Exists(appDataPath)) { 30 | Directory.CreateDirectory(appDataPath); 31 | } 32 | 33 | groupIdFilePath = Path.Combine(appDataPath, "gid"); 34 | 35 | 36 | } 37 | 38 | public bool IsExist() { 39 | IntPtr hWnd = NativeMethods.FindWindow(null, windowTitle); 40 | return hWnd != IntPtr.Zero; 41 | } 42 | 43 | public void Activate() { 44 | IntPtr hWnd = NativeMethods.FindWindow(null, windowTitle); 45 | if (hWnd != IntPtr.Zero) { 46 | NativeMethods.ShowWindow(hWnd, NativeMethods.SW_RESTORE); 47 | NativeMethods.SetForegroundWindow(hWnd); 48 | UpdateFile(); 49 | 50 | } 51 | else { 52 | 53 | string executablePath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "AppGroup.exe"); 54 | 55 | using (Process process = new Process()) { 56 | process.StartInfo = new ProcessStartInfo { 57 | FileName = executablePath, 58 | Arguments = "EditGroupWindow", 59 | UseShellExecute = false, 60 | RedirectStandardOutput = true, 61 | RedirectStandardError = true, 62 | CreateNoWindow = true 63 | }; 64 | 65 | process.Start(); 66 | } 67 | 68 | 69 | UpdateFile(); 70 | 71 | } 72 | 73 | 74 | } 75 | 76 | private bool UpdateFile() { 77 | 78 | try { 79 | File.WriteAllText(groupIdFilePath, groupId.ToString()); 80 | 81 | return true; 82 | } 83 | catch (Exception ex) { 84 | Debug.WriteLine($"Direct file update failed: {ex.Message}"); 85 | 86 | return false; 87 | } 88 | } 89 | 90 | 91 | 92 | } 93 | } -------------------------------------------------------------------------------- /AppGroup/EditGroupWindow.xaml: -------------------------------------------------------------------------------- 1 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 0,0,0,-5 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 35 | 36 | 37 | 38 | 39 | 40 | Tooltip 41 | 42 | 43 | 44 | 45 | Argument 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 61 | 62 | 0,0,0,0 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | Grid Icon 77 | 78 | 81 | 82 | 83 | 84 | 85 | 98 | 99 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | Group Name 139 | 140 | 141 | 142 | 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 | Columns 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | 200 | 201 | 202 | 206 | 207 | 208 | 209 | 210 | 211 | False 212 | 213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | 223 | 224 | 225 | 228 | 232 | 233 | 234 | 235 | 236 | 237 | 238 | 40 | 41 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | Groups 68 | 69 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 87 | 90 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | False 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 | 174 | 175 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | 200 | No Groups Found! 201 | Click + to create a Group 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | 210 | 211 | 212 | 213 | -------------------------------------------------------------------------------- /AppGroup/MainWindow.xaml.cs: -------------------------------------------------------------------------------- 1 | 2 | using Microsoft.UI; 3 | using Microsoft.UI.Composition.SystemBackdrops; 4 | using Microsoft.UI.Dispatching; 5 | using Microsoft.UI.Windowing; 6 | using Microsoft.UI.Xaml; 7 | using Microsoft.UI.Xaml.Controls; 8 | using Microsoft.UI.Xaml.Media.Imaging; 9 | using System; 10 | using System.Collections.Concurrent; 11 | using System.Collections.Generic; 12 | using System.Collections.ObjectModel; 13 | using System.ComponentModel; 14 | using System.Diagnostics; 15 | using System.IO; 16 | using System.Linq; 17 | using System.Runtime.InteropServices; 18 | using System.Runtime.InteropServices.WindowsRuntime; 19 | using System.Text.Json; 20 | using System.Text.Json.Nodes; 21 | using System.Text.RegularExpressions; 22 | using System.Threading; 23 | using System.Threading.Tasks; 24 | using Windows.Graphics; 25 | using Windows.Graphics.Imaging; 26 | using Windows.Storage; 27 | using Windows.Storage.FileProperties; 28 | using Windows.Storage.Streams; 29 | using WinRT.Interop; 30 | using WinUIEx; 31 | 32 | namespace AppGroup { 33 | public class GroupItem : INotifyPropertyChanged { 34 | public int GroupId { get; set; } 35 | private string groupName; 36 | public string GroupName { 37 | get => groupName; 38 | set { 39 | if (groupName != value) { 40 | groupName = value; 41 | OnPropertyChanged(nameof(GroupName)); 42 | } 43 | } 44 | } 45 | private string groupIcon; 46 | public string GroupIcon { 47 | get => groupIcon; 48 | set { 49 | if (groupIcon != value) { 50 | groupIcon = value; 51 | OnPropertyChanged(nameof(GroupIcon)); 52 | } 53 | } 54 | } 55 | private List pathIcons; 56 | public List PathIcons { 57 | get => pathIcons; 58 | set { 59 | if (pathIcons != value) { 60 | pathIcons = value; 61 | OnPropertyChanged(nameof(PathIcons)); 62 | } 63 | } 64 | } 65 | 66 | public string AdditionalIconsText { 67 | get { 68 | return AdditionalIconsCount > 0 ? $"+{AdditionalIconsCount}" : string.Empty; 69 | } 70 | } 71 | private int additionalIconsCount; 72 | 73 | public int AdditionalIconsCount { 74 | get => additionalIconsCount; 75 | set { 76 | if (additionalIconsCount != value) { 77 | additionalIconsCount = value; 78 | OnPropertyChanged(nameof(AdditionalIconsCount)); 79 | OnPropertyChanged(nameof(AdditionalIconsText)); 80 | } 81 | } 82 | } 83 | 84 | // New properties for Tooltip and Args 85 | public Dictionary Tooltips { get; set; } = new Dictionary(); 86 | public Dictionary Args { get; set; } = new Dictionary(); 87 | 88 | public event PropertyChangedEventHandler PropertyChanged; 89 | 90 | protected void OnPropertyChanged(string propertyName) { 91 | PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); 92 | } 93 | } 94 | 95 | 96 | public sealed partial class MainWindow : WinUIEx.WindowEx { 97 | // Private fields 98 | private readonly Dictionary _openEditWindows = new Dictionary(); 99 | private BackupHelper _backupHelper; 100 | private ObservableCollection GroupItems; 101 | private FileSystemWatcher _fileWatcher; 102 | private readonly object _loadLock = new object(); 103 | private bool _isLoading = false; 104 | private string tempIcon; 105 | private readonly IconHelper _iconHelper; 106 | private DispatcherTimer debounceTimer; 107 | private SupportDialogHelper _supportDialogHelper; 108 | 109 | public MainWindow() { 110 | 111 | this.InitializeComponent(); 112 | 113 | _backupHelper = new BackupHelper(this); 114 | 115 | GroupItems = new ObservableCollection(); 116 | GroupListView.ItemsSource = GroupItems; 117 | _iconHelper = new IconHelper(); 118 | 119 | this.CenterOnScreen(); 120 | this.MinHeight = 600; 121 | this.MinWidth = 530; 122 | 123 | this.ExtendsContentIntoTitleBar = true; 124 | var iconPath = Path.Combine(AppContext.BaseDirectory, "AppGroup.ico"); 125 | this.AppWindow.SetIcon(iconPath); 126 | 127 | _ = LoadGroupsAsync(); 128 | 129 | SetupFileWatcher(); 130 | 131 | ThemeHelper.UpdateTitleBarColors(this); 132 | debounceTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(300) }; 133 | debounceTimer.Tick += FilterGroups; 134 | 135 | _supportDialogHelper = new SupportDialogHelper(this); 136 | NativeMethods.SetCurrentProcessExplicitAppUserModelID("AppGroup.Main"); 137 | } 138 | 139 | 140 | private void SearchTextBox_TextChanged(object sender, TextChangedEventArgs e) { 141 | debounceTimer.Stop(); 142 | debounceTimer.Start(); 143 | } 144 | 145 | private void FilterGroups(object sender, object e) { 146 | debounceTimer.Stop(); 147 | string searchQuery = SearchTextBox.Text.ToLower(); 148 | var filteredGroups = GroupItems.Where(group => group.GroupName.ToLower().Contains(searchQuery)).ToList(); 149 | GroupListView.ItemsSource = filteredGroups.Count > 0 ? filteredGroups : GroupItems; 150 | GroupsCount.Text = GroupListView.Items.Count > 1 151 | ? GroupListView.Items.Count.ToString() + " Groups" 152 | : GroupListView.Items.Count == 1 153 | ? "1 Group" 154 | : ""; 155 | } 156 | 157 | 158 | 159 | 160 | private readonly SemaphoreSlim _semaphore = new SemaphoreSlim(1, 1); 161 | public async Task UpdateGroupItemAsync(string jsonFilePath) { 162 | await _semaphore.WaitAsync(); 163 | try { 164 | string jsonContent = await File.ReadAllTextAsync(jsonFilePath); 165 | JsonNode jsonObject = JsonNode.Parse(jsonContent ?? "{}") ?? new JsonObject(); 166 | var groupDictionary = jsonObject.AsObject(); 167 | 168 | var tasks = groupDictionary.Select(async property => { 169 | if (int.TryParse(property.Key, out int groupId)) { 170 | var existingItem = GroupItems.FirstOrDefault(item => item.GroupId == groupId); 171 | if (existingItem != null) { 172 | string newGroupName = property.Value?["groupName"]?.GetValue(); 173 | string newGroupIcon = property.Value?["groupIcon"]?.GetValue(); 174 | 175 | existingItem.GroupName = newGroupName; 176 | existingItem.GroupIcon = null; 177 | existingItem.GroupIcon = IconHelper.FindOrigIcon(newGroupIcon); 178 | 179 | var paths = property.Value?["path"]?.AsObject(); 180 | if (paths?.Count > 0) { 181 | var iconTasks = paths 182 | .Where(p => p.Value != null) 183 | .Select(async path => { 184 | string filePath = path.Key; 185 | string tooltip = path.Value["tooltip"]?.GetValue(); 186 | string args = path.Value["args"]?.GetValue(); 187 | 188 | existingItem.Tooltips[filePath] = tooltip; 189 | existingItem.Args[filePath] = args; 190 | 191 | return await IconCache.GetIconPathAsync(filePath); 192 | }) 193 | .ToList(); 194 | 195 | var iconPaths = await Task.WhenAll(iconTasks); 196 | var validIconPaths = iconPaths.Where(p => !string.IsNullOrEmpty(p)).ToList(); 197 | 198 | // Limit to 7 icons 199 | int maxIconsToShow = 7; 200 | existingItem.PathIcons = validIconPaths.Take(maxIconsToShow).ToList(); 201 | existingItem.AdditionalIconsCount = Math.Max(0, validIconPaths.Count - maxIconsToShow); 202 | } 203 | } 204 | else { 205 | var newItem = await CreateGroupItemAsync(groupId, property.Value); 206 | GroupItems.Add(newItem); 207 | } 208 | } 209 | }).ToList(); 210 | 211 | await Task.WhenAll(tasks); 212 | 213 | // Update UI elements outside the loop to avoid redundant updates 214 | GroupsCount.Text = GroupListView.Items.Count > 1 215 | ? GroupListView.Items.Count.ToString() + " Groups" 216 | : GroupListView.Items.Count == 1 217 | ? "1 Group" 218 | : ""; 219 | EmptyView.Visibility = GroupListView.Items.Count == 0 ? Visibility.Visible : Visibility.Collapsed; 220 | } 221 | finally { 222 | _semaphore.Release(); 223 | } 224 | } 225 | 226 | private void SetupFileWatcher() { 227 | string jsonFilePath = JsonConfigHelper.GetDefaultConfigPath(); 228 | string directoryPath = Path.GetDirectoryName(jsonFilePath); 229 | string fileName = Path.GetFileName(jsonFilePath); 230 | 231 | _fileWatcher = new FileSystemWatcher(directoryPath, fileName) { 232 | NotifyFilter = NotifyFilters.LastWrite 233 | }; 234 | 235 | _fileWatcher.Changed += (s, e) => 236 | { 237 | DispatcherQueue.TryEnqueue(async () => 238 | { 239 | if (!IsFileInUse(jsonFilePath)) { 240 | await UpdateGroupItemAsync(jsonFilePath); 241 | } 242 | }); 243 | }; 244 | 245 | _fileWatcher.EnableRaisingEvents = true; 246 | } 247 | 248 | private bool IsFileInUse(string filePath) { 249 | try { 250 | using (FileStream fs = File.Open(filePath, FileMode.Open, FileAccess.ReadWrite, FileShare.None)) { 251 | fs.Close(); 252 | } 253 | return false; 254 | } 255 | catch (IOException) { 256 | return true; 257 | } 258 | } 259 | 260 | private async void Reload(object sender, RoutedEventArgs e) { 261 | _ = LoadGroupsAsync(); 262 | 263 | 264 | } 265 | 266 | 267 | private readonly SemaphoreSlim _loadingSemaphore = new SemaphoreSlim(1, 1); 268 | private readonly CancellationTokenSource _loadCancellationSource = new CancellationTokenSource(); 269 | 270 | 271 | 272 | 273 | private async Task> ProcessGroupsInParallelAsync( 274 | JsonObject groupDictionary, 275 | CancellationToken cancellationToken) { 276 | var options = new ParallelOptions { 277 | MaxDegreeOfParallelism = Environment.ProcessorCount, 278 | CancellationToken = cancellationToken 279 | }; 280 | 281 | var newGroupItems = new ConcurrentBag(); 282 | 283 | await Parallel.ForEachAsync( 284 | groupDictionary, 285 | options, 286 | async (property, token) => { 287 | if (int.TryParse(property.Key, out int groupId)) { 288 | try { 289 | var groupItem = await CreateGroupItemAsync(groupId, property.Value); 290 | newGroupItems.Add(groupItem); 291 | } 292 | catch (Exception ex) { 293 | Debug.WriteLine($"Error processing group {groupId}: {ex.Message}"); 294 | } 295 | } 296 | }); 297 | 298 | return newGroupItems 299 | .OrderBy(g => g.GroupId) 300 | .ToList(); 301 | } 302 | 303 | private async Task> ProcessGroupsSequentiallyAsync( 304 | JsonObject groupDictionary, 305 | CancellationToken cancellationToken) { 306 | var newGroupItems = new List(); 307 | 308 | foreach (var property in groupDictionary) { 309 | cancellationToken.ThrowIfCancellationRequested(); 310 | 311 | if (int.TryParse(property.Key, out int groupId)) { 312 | try { 313 | var groupItem = await CreateGroupItemAsync(groupId, property.Value); 314 | 315 | newGroupItems.Add(groupItem); 316 | } 317 | catch (Exception ex) { 318 | Debug.WriteLine($"Error processing group {groupId}: {ex.Message}"); 319 | } 320 | } 321 | } 322 | 323 | return newGroupItems 324 | .OrderBy(g => g.GroupId) 325 | .ToList(); 326 | } 327 | 328 | private void HandleLoadingError(Exception ex) { 329 | Debug.WriteLine($"Critical error loading groups: {ex.Message}"); 330 | 331 | DispatcherQueue.TryEnqueue(() => { 332 | }); 333 | } 334 | public async Task LoadGroupsAsync() { 335 | if (!await _loadingSemaphore.WaitAsync(0)) { 336 | return; 337 | } 338 | 339 | try { 340 | using var cancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(_loadCancellationSource.Token); 341 | cancellationTokenSource.CancelAfter(TimeSpan.FromSeconds(5)); 342 | 343 | DispatcherQueue.TryEnqueue(() => 344 | { 345 | GroupItems.Clear(); 346 | }); 347 | 348 | string jsonFilePath = JsonConfigHelper.GetDefaultConfigPath(); 349 | string jsonContent = await File.ReadAllTextAsync(jsonFilePath, cancellationTokenSource.Token) 350 | .ConfigureAwait(false); 351 | 352 | JsonNode jsonObject = JsonNode.Parse(jsonContent ?? "{}") ?? new JsonObject(); 353 | var groupDictionary = jsonObject.AsObject(); 354 | 355 | var processingStrategy = groupDictionary.Count >= 5 356 | ? ProcessGroupsInParallelAsync(groupDictionary, cancellationTokenSource.Token) 357 | : ProcessGroupsSequentiallyAsync(groupDictionary, cancellationTokenSource.Token); 358 | 359 | var updatedGroupItems = await processingStrategy 360 | .ConfigureAwait(false); 361 | 362 | DispatcherQueue.TryEnqueue(async () => 363 | { 364 | GroupItems.Clear(); 365 | foreach (var item in updatedGroupItems) { 366 | // Check if the item already exists in GroupItems 367 | if (!GroupItems.Any(existingItem => existingItem.GroupId == item.GroupId)) { 368 | GroupItems.Add(item); 369 | if (GroupListView.Items.Count == 0) { 370 | EmptyView.Visibility = Visibility.Visible; 371 | } 372 | else { 373 | EmptyView.Visibility = Visibility.Collapsed; 374 | } 375 | } 376 | 377 | 378 | } 379 | GroupsCount.Text = GroupListView.Items.Count > 1 380 | ? GroupListView.Items.Count.ToString() + " Groups" 381 | : GroupListView.Items.Count == 1 382 | ? "1 Group" 383 | : ""; 384 | 385 | 386 | }); 387 | } 388 | catch (OperationCanceledException) { 389 | Debug.WriteLine("Group loading timed out."); 390 | } 391 | catch (Exception ex) { 392 | HandleLoadingError(ex); 393 | } 394 | finally { 395 | _loadingSemaphore.Release(); 396 | } 397 | } 398 | 399 | 400 | 401 | private async Task CreateGroupItemAsync(int groupId, JsonNode groupNode) { 402 | string groupName = groupNode?["groupName"]?.GetValue(); 403 | string groupIcon = IconHelper.FindOrigIcon(groupNode?["groupIcon"]?.GetValue()); 404 | 405 | var groupItem = new GroupItem { 406 | GroupId = groupId, 407 | GroupName = groupName, 408 | GroupIcon = groupIcon, 409 | PathIcons = new List(), 410 | Tooltips = new Dictionary(), 411 | Args = new Dictionary() 412 | }; 413 | 414 | var paths = groupNode?["path"]?.AsObject(); 415 | if (paths?.Count > 0) { 416 | string outputDirectory = Path.Combine( 417 | Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), 418 | "AppGroup", 419 | "Icons" 420 | ); 421 | Directory.CreateDirectory(outputDirectory); 422 | 423 | var iconTasks = paths 424 | .Where(p => p.Value != null) 425 | .Select(async path => { 426 | string filePath = path.Key; 427 | string tooltip = path.Value["tooltip"]?.GetValue(); 428 | string args = path.Value["args"]?.GetValue(); 429 | 430 | groupItem.Tooltips[filePath] = tooltip; 431 | groupItem.Args[filePath] = args; 432 | 433 | // Force icon regeneration if not exists 434 | string cachedIconPath = await IconCache.GetIconPathAsync(filePath); 435 | 436 | // Additional verification to ensure icon is actually generated 437 | if (string.IsNullOrEmpty(cachedIconPath) || !File.Exists(cachedIconPath)) { 438 | cachedIconPath = await ReGenerateIconAsync(filePath, outputDirectory); 439 | } 440 | 441 | return cachedIconPath; 442 | }) 443 | .ToList(); 444 | 445 | var iconPaths = await Task.WhenAll(iconTasks); 446 | var validIconPaths = iconPaths.Where(p => !string.IsNullOrEmpty(p) && File.Exists(p)).ToList(); 447 | 448 | // Limit to 7 icons 449 | int maxIconsToShow = 7; 450 | groupItem.PathIcons.AddRange(validIconPaths.Take(maxIconsToShow)); 451 | groupItem.AdditionalIconsCount = Math.Max(0, validIconPaths.Count - maxIconsToShow); 452 | } 453 | 454 | return groupItem; 455 | } 456 | 457 | private async Task ReGenerateIconAsync(string filePath, string outputDirectory) { 458 | try { 459 | // Force regeneration of icon 460 | var regeneratedIconPath = await IconHelper.ExtractIconAndSaveAsync(filePath, outputDirectory, TimeSpan.FromSeconds(2)); 461 | 462 | if (regeneratedIconPath != null && File.Exists(regeneratedIconPath)) { 463 | // Compute cache key and update cache 464 | string cacheKey = IconCache.ComputeFileCacheKey(filePath); 465 | IconCache._iconCache[cacheKey] = regeneratedIconPath; 466 | IconCache.SaveIconCache(); 467 | 468 | return regeneratedIconPath; 469 | } 470 | } 471 | catch (Exception ex) { 472 | Debug.WriteLine($"Icon regeneration failed for {filePath}: {ex.Message}"); 473 | } 474 | 475 | return null; 476 | } 477 | 478 | 479 | 480 | private async void ExportBackupButton_Click(object sender, RoutedEventArgs e) { 481 | await _backupHelper.ExportBackupAsync(); 482 | } 483 | 484 | private async void ImportBackupButton_Click(object sender, RoutedEventArgs e) { 485 | await _backupHelper.ImportBackupAsync(); 486 | } 487 | 488 | 489 | 490 | private void AddGroup(object sender, RoutedEventArgs e) { 491 | int groupId = JsonConfigHelper.GetNextGroupId(); 492 | 493 | EditGroupHelper editGroup = new EditGroupHelper("Edit Group", groupId); 494 | editGroup.Activate(); 495 | } 496 | private async void GitHubButton_Click(object sender, RoutedEventArgs e) { 497 | var uri = new Uri("https://github.com/iandiv"); 498 | await Windows.System.Launcher.LaunchUriAsync(uri); 499 | } 500 | private async void CoffeeButton_Click(object sender, RoutedEventArgs e) { 501 | var uri = new Uri("https://ko-fi.com/iandiv/tip"); 502 | await Windows.System.Launcher.LaunchUriAsync(uri); 503 | } 504 | private void EditButton_Click(object sender, RoutedEventArgs e) { 505 | if (sender is Button button && button.DataContext is GroupItem selectedGroup) { 506 | 507 | EditGroupHelper editGroup = new EditGroupHelper("Edit Group", selectedGroup.GroupId); 508 | 509 | editGroup.Activate(); 510 | } 511 | } 512 | private async void DeleteButton_Click(object sender, RoutedEventArgs e) { 513 | if (sender is MenuFlyoutItem menuItem && menuItem.DataContext is GroupItem selectedGroup) { 514 | ContentDialog deleteDialog = new ContentDialog { 515 | Title = "Delete", 516 | Content = $"Are you sure you want to delete the group \"{selectedGroup.GroupName}\"?", 517 | PrimaryButtonText = "Delete", 518 | CloseButtonText = "Cancel", 519 | DefaultButton = ContentDialogButton.Close, 520 | XamlRoot = this.Content.XamlRoot 521 | }; 522 | 523 | var result = await deleteDialog.ShowAsync(); 524 | if (result == ContentDialogResult.Primary) { 525 | string filePath = JsonConfigHelper.GetDefaultConfigPath(); 526 | JsonConfigHelper.DeleteGroupFromJson(filePath, selectedGroup.GroupId); 527 | await LoadGroupsAsync(); 528 | } 529 | } 530 | } 531 | private async void DuplicateButton_Click(object sender, RoutedEventArgs e) { 532 | if (sender is MenuFlyoutItem menuItem && menuItem.DataContext is GroupItem selectedGroup) { 533 | string filePath = JsonConfigHelper.GetDefaultConfigPath(); 534 | JsonConfigHelper.DuplicateGroupInJson(filePath, selectedGroup.GroupId); 535 | await LoadGroupsAsync(); 536 | } 537 | } 538 | private void OpenLocationButton_Click(object sender, RoutedEventArgs e) { 539 | if (sender is MenuFlyoutItem menuItem && menuItem.DataContext is GroupItem selectedGroup) { 540 | JsonConfigHelper.OpenGroupFolder(selectedGroup.GroupId); 541 | } 542 | } 543 | 544 | 545 | 546 | 547 | private void GroupListView_SelectionChanged(object sender, SelectionChangedEventArgs e) { 548 | if (GroupListView.SelectedItem is GroupItem selectedGroup) { 549 | EditGroupWindow editGroupWindow = new EditGroupWindow(selectedGroup.GroupId); 550 | editGroupWindow.Activate(); 551 | } 552 | } 553 | } 554 | } -------------------------------------------------------------------------------- /AppGroup/NativeMethods.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Diagnostics; 4 | using System.Drawing; 5 | using System.Linq; 6 | using System.Runtime.InteropServices; 7 | using System.Text; 8 | using System.Threading.Tasks; 9 | 10 | namespace AppGroup { 11 | public static class NativeMethods { 12 | 13 | 14 | 15 | [DllImport("user32.dll", SetLastError = true)] 16 | public static extern IntPtr FindWindow(string lpClassName, string lpWindowName); 17 | 18 | [DllImport("user32.dll")] 19 | [return: MarshalAs(UnmanagedType.Bool)] 20 | public static extern bool SetForegroundWindow(IntPtr hWnd); 21 | 22 | [DllImport("user32.dll", CharSet = CharSet.Auto)] 23 | public static extern bool ShowWindow(IntPtr hWnd, int nCmdShow); 24 | 25 | [DllImport("user32.dll")] 26 | [return: MarshalAs(UnmanagedType.Bool)] 27 | public static extern bool GetCursorPos(out POINT lpPoint); 28 | 29 | [DllImport("user32.dll")] 30 | public static extern IntPtr MonitorFromPoint(POINT pt, uint dwFlags); 31 | 32 | [DllImport("user32.dll")] 33 | public static extern bool GetWindowRect(IntPtr hWnd, out RECT lpRect); 34 | 35 | [DllImport("user32.dll")] 36 | public static extern bool SetWindowPos(IntPtr hWnd, IntPtr hWndInsertAfter, int X, int Y, int cx, int cy, uint uFlags); 37 | 38 | [StructLayout(LayoutKind.Sequential)] 39 | public struct POINT { 40 | public int X; 41 | public int Y; 42 | } 43 | 44 | [StructLayout(LayoutKind.Sequential)] 45 | public struct MONITORINFO { 46 | public uint cbSize; 47 | public RECT rcMonitor; 48 | public RECT rcWork; 49 | public uint dwFlags; 50 | } 51 | 52 | [StructLayout(LayoutKind.Sequential)] 53 | public struct RECT { 54 | public int left; 55 | public int top; 56 | public int right; 57 | public int bottom; 58 | } 59 | 60 | public const int SW_HIDE = 0; 61 | public const int SW_SHOW = 5; 62 | public const int SW_RESTORE = 9; 63 | public const uint MONITOR_DEFAULTTONEAREST = 0x00000002; 64 | public const uint SWP_NOSIZE = 0x0001; 65 | public const uint SWP_NOZORDER = 0x0004; 66 | public const uint SWP_ASYNCWINDOWPOS = 0x4000; 67 | public const int MDT_EFFECTIVE_DPI = 0; 68 | public const int SM_CXSCREEN = 0; 69 | public const int SM_CYSCREEN = 1; 70 | public const int WM_USER = 0x0400; 71 | public const int SW_MAXIMIZE = 3; 72 | public const int SW_MINIMIZE = 6; 73 | public const int SW_NORMAL = 1; 74 | // Window Messages 75 | public const int WM_COPYDATA = 0x004A; 76 | 77 | [DllImport("shell32.dll", CharSet = CharSet.Unicode)] 78 | public static extern void SetCurrentProcessExplicitAppUserModelID([MarshalAs(UnmanagedType.LPWStr)] string AppID); 79 | 80 | 81 | 82 | 83 | 84 | 85 | [DllImport("psapi.dll")] 86 | public static extern int EmptyWorkingSet(IntPtr hwProc); 87 | 88 | public static void PositionWindowAboveTaskbar(IntPtr hWnd) { 89 | try { 90 | // Get window dimensions 91 | NativeMethods.RECT windowRect; 92 | if (!NativeMethods.GetWindowRect(hWnd, out windowRect)) { 93 | return; 94 | } 95 | int windowWidth = windowRect.right - windowRect.left; 96 | int windowHeight = windowRect.bottom - windowRect.top; 97 | 98 | // Get current cursor position 99 | NativeMethods.POINT cursorPos; 100 | if (!NativeMethods.GetCursorPos(out cursorPos)) { 101 | return; 102 | } 103 | 104 | // Get monitor information 105 | IntPtr monitor = NativeMethods.MonitorFromPoint(cursorPos, NativeMethods.MONITOR_DEFAULTTONEAREST); 106 | NativeMethods.MONITORINFO monitorInfo = new NativeMethods.MONITORINFO(); 107 | monitorInfo.cbSize = (uint)Marshal.SizeOf(typeof(NativeMethods.MONITORINFO)); 108 | if (!NativeMethods.GetMonitorInfo(monitor, ref monitorInfo)) { 109 | return; 110 | } 111 | 112 | // Calculate position based on taskbar position 113 | float dpiScale = GetDpiScaleForMonitor(monitor); 114 | int baseTaskbarHeight = 52; 115 | int taskbarHeight = (int)(baseTaskbarHeight * dpiScale); 116 | 117 | // Define a consistent spacing value for all sides 118 | int spacing = 8; // Pixels of space between window and taskbar 119 | 120 | // Check if taskbar is auto-hidden and adjust spacing if needed 121 | bool isTaskbarAutoHide = IsTaskbarAutoHide(); 122 | if (isTaskbarAutoHide) { 123 | // When taskbar is auto-hidden, we need to ensure we provide enough space 124 | // The typical auto-hide taskbar shows a few pixels even when hidden 125 | int autoHideSpacing = (int)((baseTaskbarHeight) * dpiScale); // Additional space for auto-hide taskbar 126 | spacing += autoHideSpacing; 127 | } 128 | 129 | // Determine taskbar position by comparing work area with monitor area 130 | TaskbarPosition taskbarPosition = GetTaskbarPosition(monitorInfo); 131 | 132 | // Initial position (centered horizontally relative to cursor) 133 | int x = cursorPos.X - (windowWidth / 2); 134 | int y; 135 | 136 | // Set position based on taskbar position 137 | switch (taskbarPosition) { 138 | case TaskbarPosition.Top: 139 | // For auto-hide, work area might be full screen, so use monitor top with spacing 140 | if (isTaskbarAutoHide) 141 | y = monitorInfo.rcMonitor.top + spacing; 142 | else 143 | y = monitorInfo.rcWork.top + spacing; 144 | break; 145 | case TaskbarPosition.Bottom: 146 | // For auto-hide, work area might be full screen, so use monitor bottom with spacing 147 | if (isTaskbarAutoHide) 148 | y = monitorInfo.rcMonitor.bottom - windowHeight - spacing + 5; 149 | else 150 | y = monitorInfo.rcWork.bottom - windowHeight - spacing; 151 | break; 152 | case TaskbarPosition.Left: 153 | // For auto-hide, work area might be full screen, so use monitor left with spacing 154 | if (isTaskbarAutoHide) 155 | x = monitorInfo.rcMonitor.left + spacing; 156 | else 157 | x = monitorInfo.rcWork.left + spacing; 158 | y = cursorPos.Y - (windowHeight / 2); 159 | break; 160 | case TaskbarPosition.Right: 161 | // For auto-hide, work area might be full screen, so use monitor right with spacing 162 | if (isTaskbarAutoHide) 163 | x = monitorInfo.rcMonitor.right - windowWidth - spacing; 164 | else 165 | x = monitorInfo.rcWork.right - windowWidth - spacing; 166 | y = cursorPos.Y - (windowHeight / 2); 167 | break; 168 | default: 169 | // Default to bottom positioning 170 | if (isTaskbarAutoHide) 171 | y = monitorInfo.rcMonitor.bottom - windowHeight -spacing; 172 | else 173 | y = monitorInfo.rcWork.bottom - windowHeight - spacing; 174 | break; 175 | } 176 | 177 | // Ensure window stays within monitor bounds horizontally 178 | if (x < monitorInfo.rcWork.left) 179 | x = monitorInfo.rcWork.left; 180 | if (x + windowWidth > monitorInfo.rcWork.right) 181 | x = monitorInfo.rcWork.right - windowWidth; 182 | 183 | // Ensure window stays within monitor bounds vertically 184 | if (y < monitorInfo.rcWork.top) 185 | y = monitorInfo.rcWork.top; 186 | if (y + windowHeight > monitorInfo.rcWork.bottom) 187 | y = monitorInfo.rcWork.bottom - windowHeight; 188 | 189 | // Move the window (maintain size, only change position) 190 | NativeMethods.SetWindowPos(hWnd, IntPtr.Zero, x, y, 0, 0, NativeMethods.SWP_NOSIZE | NativeMethods.SWP_NOZORDER); 191 | } 192 | catch (Exception ex) { 193 | Debug.WriteLine($"Error positioning window: {ex.Message}"); 194 | } 195 | } 196 | 197 | /// 198 | /// Determines the position of the taskbar based on monitor work area 199 | /// 200 | /// 201 | /// Determines the position of the taskbar based on monitor work area 202 | /// 203 | private static TaskbarPosition GetTaskbarPosition(NativeMethods.MONITORINFO monitorInfo) { 204 | // If work area equals monitor area (which can happen with auto-hide taskbar), 205 | // fall back to detecting taskbar position via other means 206 | if (monitorInfo.rcWork.top == monitorInfo.rcMonitor.top && 207 | monitorInfo.rcWork.bottom == monitorInfo.rcMonitor.bottom && 208 | monitorInfo.rcWork.left == monitorInfo.rcMonitor.left && 209 | monitorInfo.rcWork.right == monitorInfo.rcMonitor.right) { 210 | // For auto-hide taskbar, try to get the position using AppBar info 211 | return GetTaskbarPositionFromAppBarInfo(); 212 | } 213 | 214 | // Compare work area with screen area to determine taskbar position 215 | if (monitorInfo.rcWork.top > monitorInfo.rcMonitor.top) 216 | return TaskbarPosition.Top; 217 | else if (monitorInfo.rcWork.bottom < monitorInfo.rcMonitor.bottom) 218 | return TaskbarPosition.Bottom; 219 | else if (monitorInfo.rcWork.left > monitorInfo.rcMonitor.left) 220 | return TaskbarPosition.Left; 221 | else if (monitorInfo.rcWork.right < monitorInfo.rcMonitor.right) 222 | return TaskbarPosition.Right; 223 | else 224 | return TaskbarPosition.Bottom; // Default 225 | } 226 | 227 | private enum TaskbarPosition { 228 | Top, 229 | Bottom, 230 | Left, 231 | Right 232 | } 233 | private static bool IsTaskbarAutoHide() { 234 | NativeMethods.APPBARDATA appBarData = new NativeMethods.APPBARDATA(); 235 | appBarData.cbSize = (uint)Marshal.SizeOf(typeof(NativeMethods.APPBARDATA)); 236 | 237 | // Get taskbar state 238 | IntPtr result = NativeMethods.SHAppBarMessage(NativeMethods.ABM_GETSTATE, ref appBarData); 239 | 240 | // Check if auto-hide bit is set (ABS_AUTOHIDE = 0x01) 241 | return ((uint)result & 0x01) != 0; 242 | } 243 | 244 | 245 | /// 246 | /// Gets the taskbar position using AppBar information (works for auto-hide taskbars) 247 | /// 248 | private static TaskbarPosition GetTaskbarPositionFromAppBarInfo() { 249 | NativeMethods.APPBARDATA appBarData = new NativeMethods.APPBARDATA(); 250 | appBarData.cbSize = (uint)Marshal.SizeOf(typeof(NativeMethods.APPBARDATA)); 251 | 252 | // Get taskbar position data 253 | IntPtr result = NativeMethods.SHAppBarMessage(NativeMethods.ABM_GETTASKBARPOS, ref appBarData); 254 | if (result != IntPtr.Zero) { 255 | // uEdge field contains the edge the taskbar is docked to 256 | switch (appBarData.uEdge) { 257 | case NativeMethods.ABE_TOP: return TaskbarPosition.Top; 258 | case NativeMethods.ABE_BOTTOM: return TaskbarPosition.Bottom; 259 | case NativeMethods.ABE_LEFT: return TaskbarPosition.Left; 260 | case NativeMethods.ABE_RIGHT: return TaskbarPosition.Right; 261 | } 262 | } 263 | 264 | // Default to bottom if we couldn't determine 265 | return TaskbarPosition.Bottom; 266 | } 267 | // Constants for SHAppBarMessage 268 | public const uint ABM_GETSTATE = 0x4; 269 | public const uint ABM_GETTASKBARPOS = 0x5; 270 | 271 | // Constants for taskbar edge positions 272 | public const int ABE_LEFT = 0; 273 | public const int ABE_TOP = 1; 274 | public const int ABE_RIGHT = 2; 275 | public const int ABE_BOTTOM = 3; 276 | 277 | 278 | [DllImport("shell32.dll")] 279 | public static extern IntPtr SHAppBarMessage(uint dwMessage, ref APPBARDATA pData); 280 | 281 | 282 | [StructLayout(LayoutKind.Sequential)] 283 | public struct APPBARDATA { 284 | public uint cbSize; 285 | public IntPtr hWnd; 286 | public uint uCallbackMessage; 287 | public uint uEdge; 288 | public RECT rc; 289 | public IntPtr lParam; 290 | } 291 | private static float GetDpiScaleForMonitor(IntPtr hMonitor) { 292 | try { 293 | if (Environment.OSVersion.Version.Major > 6 || 294 | (Environment.OSVersion.Version.Major == 6 && Environment.OSVersion.Version.Minor >= 3)) { 295 | uint dpiX, dpiY; 296 | // Try to get DPI for the monitor 297 | if (NativeMethods.GetDpiForMonitor(hMonitor, NativeMethods.MDT_EFFECTIVE_DPI, out dpiX, out dpiY) == 0) { 298 | return dpiX / 96.0f; 299 | } 300 | } 301 | using (Graphics g = Graphics.FromHwnd(IntPtr.Zero)) { 302 | return g.DpiX / 96.0f; 303 | } 304 | } 305 | catch { 306 | return 1.0f; 307 | } 308 | } 309 | 310 | 311 | //public static void PositionWindowAboveTaskbar(IntPtr hWnd) { 312 | // try { 313 | // // Get window dimensions 314 | // NativeMethods.RECT windowRect; 315 | // if (!NativeMethods.GetWindowRect(hWnd, out windowRect)) { 316 | // return; 317 | // } 318 | 319 | // int windowWidth = windowRect.right - windowRect.left; 320 | // int windowHeight = windowRect.bottom - windowRect.top; 321 | 322 | // // Get current cursor position 323 | // NativeMethods.POINT cursorPos; 324 | // if (!NativeMethods.GetCursorPos(out cursorPos)) { 325 | // return; 326 | // } 327 | 328 | // // Calculate new position 329 | // int x = cursorPos.X - (windowWidth / 2); 330 | 331 | // // Get monitor information 332 | // IntPtr monitor = NativeMethods.MonitorFromPoint(cursorPos, NativeMethods.MONITOR_DEFAULTTONEAREST); 333 | // NativeMethods.MONITORINFO monitorInfo = new NativeMethods.MONITORINFO(); 334 | // monitorInfo.cbSize = (uint)Marshal.SizeOf(typeof(NativeMethods.MONITORINFO)); 335 | 336 | // if (NativeMethods.GetMonitorInfo(monitor, ref monitorInfo)) { 337 | // // Calculate position based on taskbar 338 | // float dpiScale = GetDpiScaleForMonitor(monitor); 339 | // int baseTaskbarHeight = 52; 340 | // int taskbarHeight = (int)(baseTaskbarHeight * dpiScale); 341 | // int y = monitorInfo.rcMonitor.bottom - windowHeight - taskbarHeight; 342 | 343 | // //int workAreaDifference = monitorInfo.rcMonitor.bottom - monitorInfo.rcWork.bottom; 344 | 345 | // //if (workAreaDifference > 5) { 346 | // // y = monitorInfo.rcMonitor.bottom - windowHeight - workAreaDifference; 347 | // //} 348 | 349 | // // Ensure window stays within monitor bounds horizontally 350 | // if (x < monitorInfo.rcWork.left) 351 | // x = monitorInfo.rcWork.left; 352 | // if (x + windowWidth > monitorInfo.rcWork.right) 353 | // x = monitorInfo.rcWork.right - windowWidth; 354 | 355 | // // Move the window (maintain size, only change position) 356 | // NativeMethods.SetWindowPos(hWnd, IntPtr.Zero, x, y, 0, 0, NativeMethods.SWP_NOSIZE | NativeMethods.SWP_NOZORDER); 357 | // } 358 | // } 359 | // catch (Exception ex) { 360 | // Debug.WriteLine($"Error positioning window: {ex.Message}"); 361 | // } 362 | //} 363 | 364 | //private static float GetDpiScaleForMonitor(IntPtr hMonitor) { 365 | // try { 366 | // if (Environment.OSVersion.Version.Major > 6 || 367 | // (Environment.OSVersion.Version.Major == 6 && Environment.OSVersion.Version.Minor >= 3)) { 368 | 369 | // uint dpiX, dpiY; 370 | 371 | // // Try to get DPI for the monitor 372 | // if (NativeMethods.GetDpiForMonitor(hMonitor, NativeMethods.MDT_EFFECTIVE_DPI, out dpiX, out dpiY) == 0) { 373 | // return dpiX / 96.0f; 374 | // } 375 | // } 376 | 377 | // using (Graphics g = Graphics.FromHwnd(IntPtr.Zero)) { 378 | // return g.DpiX / 96.0f; 379 | // } 380 | // } 381 | // catch { 382 | // return 1.0f; 383 | // } 384 | //} 385 | 386 | 387 | [StructLayout(LayoutKind.Sequential)] 388 | public struct SHFILEINFO { 389 | public IntPtr hIcon; 390 | public int iIcon; 391 | public uint dwAttributes; 392 | [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 260)] 393 | public string szDisplayName; 394 | [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 80)] 395 | public string szTypeName; 396 | } 397 | public const uint SHGFI_ICON = 0x000000100; 398 | public const uint SHGFI_LARGEICON = 0x000000000; 399 | public const uint SHGFI_SMALLICON = 0x000000001; 400 | 401 | [DllImport("shell32.dll", CharSet = CharSet.Auto)] 402 | public static extern uint ExtractIconEx(string szFileName, int nIconIndex, 403 | IntPtr[] phiconLarge, IntPtr[] phiconSmall, uint nIcons); 404 | 405 | 406 | [DllImport("shell32.dll")] 407 | public static extern IntPtr SHGetFileInfo(string pszPath, uint dwFileAttributes, ref SHFILEINFO psfi, uint cbSizeFileInfo, uint uFlags); 408 | 409 | [DllImport("user32.dll", SetLastError = true)] 410 | public static extern bool DestroyIcon(IntPtr handle); 411 | 412 | 413 | 414 | [DllImport("user32.dll")] 415 | public static extern uint GetDpiForWindow(IntPtr hwnd); 416 | 417 | 418 | [DllImport("user32.dll")] 419 | public static extern IntPtr MonitorFromWindow(IntPtr hwnd, uint dwFlags); 420 | 421 | [DllImport("user32.dll")] 422 | [return: MarshalAs(UnmanagedType.Bool)] 423 | public static extern bool GetMonitorInfo(IntPtr hMonitor, ref MONITORINFOEX lpmi); 424 | 425 | 426 | [DllImport("Shcore.dll")] 427 | public static extern int GetDpiForMonitor(IntPtr hmonitor, int dpiType, out uint dpiX, out uint dpiY); 428 | 429 | 430 | [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Auto)] 431 | public struct MONITORINFOEX { 432 | public int cbSize; 433 | public RECT rcMonitor; 434 | public RECT rcWork; 435 | public uint dwFlags; 436 | [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 32)] 437 | public string szDevice; 438 | } 439 | 440 | 441 | 442 | 443 | [DllImport("user32.dll", SetLastError = true)] 444 | [return: MarshalAs(UnmanagedType.Bool)] 445 | public static extern bool GetMonitorInfo(IntPtr hMonitor, ref MONITORINFO lpmi); 446 | 447 | 448 | 449 | 450 | 451 | 452 | 453 | 454 | 455 | } 456 | } 457 | -------------------------------------------------------------------------------- /AppGroup/Package.appxmanifest: -------------------------------------------------------------------------------- 1 |  2 | 3 | 9 | 10 | 14 | 15 | 16 | 17 | 18 | App Group 19 | IanDiv 20 | Assets\StoreLogo.png 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 36 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | -------------------------------------------------------------------------------- /AppGroup/PopupWindow.xaml: -------------------------------------------------------------------------------- 1 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 31 | 32 | 33 | 34 | 35 | 37 | 38 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /AppGroup/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "profiles": { 3 | "AppGroup (Package)": { 4 | "commandName": "MsixPackage" 5 | }, 6 | "AppGroup (Unpackaged)": { 7 | "commandName": "Project" 8 | } 9 | } 10 | } -------------------------------------------------------------------------------- /AppGroup/SupportDialogHelper.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.UI.Dispatching; 2 | using Microsoft.UI.Xaml; 3 | using Microsoft.UI.Xaml.Controls; 4 | using System; 5 | using System.Diagnostics; 6 | using System.IO; 7 | using System.Threading.Tasks; 8 | 9 | namespace AppGroup { 10 | /// 11 | /// Helper class to handle "Support Us" dialogs that show after multiple uses of the app 12 | /// 13 | public class SupportDialogHelper { 14 | private const string USAGE_COUNT_FILENAME = "usage_count.dat"; 15 | private const int DEFAULT_DIALOG_THRESHOLD =3; 16 | 17 | private readonly int _dialogThreshold; 18 | private readonly Window _ownerWindow; 19 | private readonly string _donationUrl; 20 | private bool _checkPerformed = false; 21 | 22 | public SupportDialogHelper(Window ownerWindow, int dialogThreshold = DEFAULT_DIALOG_THRESHOLD, string donationUrl = "https://ko-fi.com/iandiv/tip") { 23 | _ownerWindow = ownerWindow ?? throw new ArgumentNullException(nameof(ownerWindow)); 24 | _dialogThreshold = dialogThreshold > 0 ? dialogThreshold : DEFAULT_DIALOG_THRESHOLD; 25 | _donationUrl = donationUrl; 26 | 27 | ScheduleUsageCheck(); 28 | } 29 | public void ScheduleUsageCheck() { 30 | if (_checkPerformed) return; 31 | 32 | DispatcherTimer startupTimer = new DispatcherTimer(); 33 | startupTimer.Interval = TimeSpan.FromMilliseconds(1000); 34 | startupTimer.Tick += (s, e) => { 35 | startupTimer.Stop(); 36 | CheckUsageAndShowDialog(); 37 | _checkPerformed = true; 38 | }; 39 | startupTimer.Start(); 40 | } 41 | 42 | 43 | public void CheckUsageAndShowDialog() { 44 | try { 45 | int usageCount = GetCurrentUsageCount(); 46 | usageCount++; 47 | SaveUsageCount(usageCount); 48 | 49 | Debug.WriteLine($"App usage count: {usageCount}"); 50 | 51 | if (usageCount % _dialogThreshold == 0) { 52 | _ownerWindow.DispatcherQueue.TryEnqueue(DispatcherQueuePriority.Normal, async () => { 53 | await ShowSupportDialogAsync(); 54 | }); 55 | } 56 | } 57 | catch (Exception ex) { 58 | Debug.WriteLine($"Error in CheckUsageAndShowDialog: {ex.Message}"); 59 | } 60 | } 61 | 62 | 63 | private int GetCurrentUsageCount() { 64 | string filePath = GetUsageFilePath(); 65 | 66 | try { 67 | if (File.Exists(filePath)) { 68 | string countText = File.ReadAllText(filePath).Trim(); 69 | if (int.TryParse(countText, out int count)) { 70 | return count; 71 | } 72 | } 73 | } 74 | catch (Exception ex) { 75 | Debug.WriteLine($"Error reading usage count: {ex.Message}"); 76 | } 77 | 78 | return 0; 79 | } 80 | 81 | 82 | private void SaveUsageCount(int count) { 83 | string filePath = GetUsageFilePath(); 84 | 85 | try { 86 | Directory.CreateDirectory(Path.GetDirectoryName(filePath)); 87 | File.WriteAllText(filePath, count.ToString()); 88 | } 89 | catch (Exception ex) { 90 | Debug.WriteLine($"Error saving usage count: {ex.Message}"); 91 | } 92 | } 93 | 94 | private string GetUsageFilePath() { 95 | string appDataFolder = Path.Combine( 96 | Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), 97 | "AppGroup" 98 | ); 99 | 100 | return Path.Combine(appDataFolder, USAGE_COUNT_FILENAME); 101 | } 102 | 103 | 104 | private async Task ShowSupportDialogAsync() { 105 | try { 106 | if (_ownerWindow == null || _ownerWindow.Content == null || _ownerWindow.Content.XamlRoot == null) { 107 | Debug.WriteLine("Cannot show dialog: Window or XamlRoot is not available"); 108 | return; 109 | } 110 | 111 | ContentDialog supportDialog = new ContentDialog { 112 | Title = "❤️ Support Us ", 113 | Content = new TextBlock { 114 | Text = "Thanks for using AppGroup!\nIf you find it useful, your support is greatly appreciated.", 115 | TextWrapping = TextWrapping.Wrap 116 | }, 117 | 118 | SecondaryButtonText = "Support Us", 119 | PrimaryButtonText = "Later", 120 | DefaultButton = ContentDialogButton.Secondary, 121 | XamlRoot = _ownerWindow.Content.XamlRoot 122 | }; 123 | 124 | var result = await supportDialog.ShowAsync(); 125 | 126 | if (result == ContentDialogResult.Secondary) { 127 | var uri = new Uri(_donationUrl); 128 | await Windows.System.Launcher.LaunchUriAsync(uri); 129 | } 130 | } 131 | catch (Exception ex) { 132 | Debug.WriteLine($"Error showing support dialog: {ex.Message}"); 133 | } 134 | } 135 | 136 | 137 | public void ResetUsageCount() { 138 | SaveUsageCount(0); 139 | } 140 | } 141 | } -------------------------------------------------------------------------------- /AppGroup/ThemeHelper.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using Microsoft.UI; 3 | using Microsoft.UI.Xaml; 4 | using Microsoft.UI.Xaml.Controls; 5 | 6 | 7 | namespace AppGroup { 8 | 9 | public static class ThemeHelper { 10 | public static void UpdateTitleBarColors(Window window) { 11 | if (window.Content is FrameworkElement root) { 12 | root.ActualThemeChanged += (sender, args) => { 13 | var titleBar = window.AppWindow.TitleBar; 14 | var isDarkMode = (window.Content as FrameworkElement)?.ActualTheme == ElementTheme.Dark; 15 | 16 | titleBar.ButtonForegroundColor = isDarkMode ? Colors.White : Colors.Black; 17 | }; 18 | 19 | var initialIsDarkMode = (window.Content as FrameworkElement)?.ActualTheme == ElementTheme.Dark; 20 | var initialTitleBar = window.AppWindow.TitleBar; 21 | initialTitleBar.ButtonForegroundColor = initialIsDarkMode ? Colors.White : Colors.Black; 22 | } 23 | } 24 | } 25 | } 26 | 27 | -------------------------------------------------------------------------------- /AppGroup/WindowHelper.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Net.NetworkInformation; 3 | using System.Runtime.InteropServices; 4 | using Microsoft.UI; 5 | using Microsoft.UI.Composition.SystemBackdrops; 6 | using Microsoft.UI.Windowing; 7 | using Microsoft.UI.Xaml; 8 | using Microsoft.UI.Xaml.Media; 9 | using Windows.Graphics; 10 | using WinRT.Interop; 11 | using WinRT; 12 | using System.Diagnostics; 13 | using System.Drawing; 14 | namespace AppGroup { 15 | public class WindowHelper { 16 | private readonly Window _window; 17 | private AppWindow _appWindow; 18 | private IntPtr _hWnd; 19 | private SystemBackdropConfiguration _configurationSource; 20 | private MicaBackdrop _micaBackdrop; 21 | private DesktopAcrylicController _acrylicController; 22 | private bool _micaEnabled; 23 | private bool _extendContent; 24 | private bool _canMaximize; 25 | private bool _centerWindow; 26 | private int _minWidth = 0; 27 | private int _minHeight = 0; 28 | 29 | public enum BackdropType { 30 | None, 31 | Mica, 32 | AcrylicBase, 33 | AcrylicThin 34 | } 35 | private BackdropType _currentBackdropType = BackdropType.None; 36 | 37 | public delegate int SUBCLASSPROC(IntPtr hWnd, uint uMsg, IntPtr wParam, IntPtr lParam, IntPtr uIdSubclass, uint dwRefData); 38 | 39 | [DllImport("Comctl32.dll", SetLastError = true)] 40 | public static extern bool SetWindowSubclass(IntPtr hWnd, SUBCLASSPROC pfnSubclass, uint uIdSubclass, uint dwRefData); 41 | 42 | [DllImport("Comctl32.dll", SetLastError = true)] 43 | public static extern int DefSubclassProc(IntPtr hWnd, uint uMsg, IntPtr wParam, IntPtr lParam); 44 | 45 | private const int WM_GETMINMAXINFO = 0x0024; 46 | 47 | private struct MINMAXINFO { 48 | public System.Drawing.Point ptReserved; 49 | public System.Drawing.Point ptMaxSize; 50 | public System.Drawing.Point ptMaxPosition; 51 | public System.Drawing.Point ptMinTrackSize; 52 | public System.Drawing.Point ptMaxTrackSize; 53 | } 54 | 55 | 56 | 57 | 58 | 59 | [DllImport("user32.dll", CharSet = CharSet.Auto)] 60 | private static extern uint GetWindowLong(IntPtr hWnd, int nIndex); 61 | 62 | [DllImport("user32.dll", CharSet = CharSet.Auto)] 63 | private static extern int SetWindowLong(IntPtr hWnd, int nIndex, uint dwNewLong); 64 | 65 | private readonly SUBCLASSPROC _subClassDelegate; 66 | 67 | public WindowHelper(Window window) { 68 | _window = window ?? throw new ArgumentNullException(nameof(window)); 69 | _subClassDelegate = new SUBCLASSPROC(WindowSubClass); 70 | InitializeWindow(); 71 | } 72 | 73 | public BackdropType CurrentBackdropType => _currentBackdropType; 74 | 75 | public bool IsMaximizable { 76 | get => _appWindow.Presenter is OverlappedPresenter presenter && presenter.IsMaximizable; 77 | set { 78 | if (_appWindow.Presenter is OverlappedPresenter presenter) { 79 | presenter.IsMaximizable = value; 80 | } 81 | } 82 | } 83 | public bool IsAlwaysOnTop { 84 | get => _appWindow.Presenter is OverlappedPresenter presenter && presenter.IsMaximizable; 85 | set { 86 | if (_appWindow.Presenter is OverlappedPresenter presenter) { 87 | presenter.IsAlwaysOnTop = value; 88 | } 89 | } 90 | } 91 | public bool IsResizable { 92 | get => _appWindow.Presenter is OverlappedPresenter presenter && presenter.IsResizable; 93 | set { 94 | if (_appWindow.Presenter is OverlappedPresenter presenter) { 95 | presenter.IsResizable = value; 96 | } 97 | } 98 | } 99 | 100 | public bool HasBorder { 101 | get => _appWindow.Presenter is OverlappedPresenter presenter && presenter.HasBorder; 102 | set { 103 | if (_appWindow.Presenter is OverlappedPresenter presenter) { 104 | presenter.SetBorderAndTitleBar(value, HasTitleBar); 105 | } 106 | } 107 | } 108 | 109 | public bool HasTitleBar { 110 | get => _appWindow.Presenter is OverlappedPresenter presenter && presenter.HasTitleBar; 111 | set { 112 | if (_appWindow.Presenter is OverlappedPresenter presenter) { 113 | presenter.SetBorderAndTitleBar(HasBorder, value); 114 | } 115 | } 116 | } 117 | 118 | public bool IsMinimizable { 119 | get => _appWindow.Presenter is OverlappedPresenter presenter && presenter.IsMinimizable; 120 | set { 121 | if (_appWindow.Presenter is OverlappedPresenter presenter) { 122 | presenter.IsMinimizable = value; 123 | } 124 | } 125 | } 126 | 127 | public AppWindow AppWindow => _appWindow; 128 | 129 | public IntPtr WindowHandle => _hWnd; 130 | 131 | 132 | 133 | public bool CenterWindow { 134 | get => _centerWindow; 135 | set { 136 | _centerWindow = value; 137 | CenterOnScreen(); 138 | } 139 | } 140 | 141 | public (int Width, int Height) MinimumSize { 142 | get => (_minWidth, _minHeight); 143 | set { 144 | _minWidth = value.Width; 145 | _minHeight = value.Height; 146 | } 147 | } 148 | 149 | public (int Width, int Height) WindowSize { 150 | get => (_appWindow.Size.Width, _appWindow.Size.Height); 151 | set => SetSize(value.Width, value.Height); 152 | } 153 | 154 | private void InitializeWindow() { 155 | _hWnd = WindowNative.GetWindowHandle(_window); 156 | var windowId = Win32Interop.GetWindowIdFromWindow(_hWnd); 157 | _appWindow = AppWindow.GetFromWindowId(windowId); 158 | 159 | SetWindowSubclass(_hWnd, _subClassDelegate, 0, 0); 160 | 161 | 162 | if (_window.Content is FrameworkElement root) { 163 | root.ActualThemeChanged += (sender, args) => { 164 | UpdateTheme(root.ActualTheme); 165 | }; 166 | } 167 | } 168 | 169 | private void UpdateTheme(ElementTheme newTheme) { 170 | if (_configurationSource != null) { 171 | _configurationSource.Theme = newTheme == ElementTheme.Dark 172 | ? SystemBackdropTheme.Dark 173 | : SystemBackdropTheme.Light; 174 | } 175 | 176 | //TrySetMicaBackdrop(); 177 | UpdateTitleBarColors(); 178 | } 179 | 180 | 181 | public static float GetDpiScaleForMonitor(IntPtr hMonitor) { 182 | try { 183 | if (Environment.OSVersion.Version.Major > 6 || 184 | (Environment.OSVersion.Version.Major == 6 && Environment.OSVersion.Version.Minor >= 3)) { 185 | 186 | uint dpiX, dpiY; 187 | 188 | // Try to get DPI for the monitor 189 | if (NativeMethods.GetDpiForMonitor(hMonitor, NativeMethods.MDT_EFFECTIVE_DPI, out dpiX, out dpiY) == 0) { 190 | return dpiX / 96.0f; 191 | } 192 | } 193 | 194 | using (Graphics g = Graphics.FromHwnd(IntPtr.Zero)) { 195 | return g.DpiX / 96.0f; 196 | } 197 | } 198 | catch { 199 | return 1.0f; 200 | } 201 | } 202 | 203 | private void RefreshThemeResources() { 204 | if (_window.Content is FrameworkElement root) { 205 | root.Resources.MergedDictionaries.Clear(); 206 | root.RequestedTheme = root.ActualTheme; 207 | } 208 | } 209 | 210 | public void SetSize(int width, int height) { 211 | _appWindow.Resize(new SizeInt32(width, height)); 212 | } 213 | 214 | 215 | public BackdropType SetSystemBackdrop(BackdropType backdropType) { 216 | CleanupSystemBackdrop(); 217 | 218 | switch (backdropType) { 219 | case BackdropType.Mica: 220 | return TrySetMicaBackdrop(); 221 | case BackdropType.AcrylicBase: 222 | return TrySetAcrylicBackdrop(false); 223 | case BackdropType.AcrylicThin: 224 | return TrySetAcrylicBackdrop(true); 225 | default: 226 | _window.SystemBackdrop = null; 227 | _currentBackdropType = BackdropType.None; 228 | return BackdropType.None; 229 | } 230 | } 231 | 232 | private BackdropType TrySetMicaBackdrop() { 233 | if (!MicaController.IsSupported() || !_micaEnabled) { 234 | _window.SystemBackdrop = null; 235 | return BackdropType.None; 236 | } 237 | 238 | if (_micaBackdrop == null) { 239 | _micaBackdrop = new MicaBackdrop(); 240 | } 241 | 242 | if (_configurationSource == null) { 243 | _configurationSource = new SystemBackdropConfiguration(); 244 | } 245 | 246 | _configurationSource.Theme = (_window.Content as FrameworkElement)?.ActualTheme == ElementTheme.Dark 247 | ? SystemBackdropTheme.Dark 248 | : SystemBackdropTheme.Light; 249 | 250 | _window.SystemBackdrop = _micaBackdrop; 251 | _currentBackdropType = BackdropType.Mica; 252 | return BackdropType.Mica; 253 | } 254 | 255 | private BackdropType TrySetAcrylicBackdrop(bool useThin) { 256 | if (!DesktopAcrylicController.IsSupported()) { 257 | _window.SystemBackdrop = null; 258 | _currentBackdropType = BackdropType.None; 259 | return BackdropType.None; 260 | } 261 | 262 | var dispatcherQueueHelper = new WindowsSystemDispatcherQueueHelper(); 263 | dispatcherQueueHelper.EnsureWindowsSystemDispatcherQueueController(); 264 | 265 | if (_configurationSource == null) { 266 | _configurationSource = new SystemBackdropConfiguration(); 267 | } 268 | 269 | _configurationSource.Theme = (_window.Content as FrameworkElement)?.ActualTheme == ElementTheme.Dark 270 | ? SystemBackdropTheme.Dark 271 | : SystemBackdropTheme.Light; 272 | 273 | _acrylicController = new DesktopAcrylicController(); 274 | _acrylicController.Kind = useThin 275 | ? DesktopAcrylicKind.Thin 276 | : DesktopAcrylicKind.Base; 277 | 278 | _acrylicController.AddSystemBackdropTarget( 279 | _window.As() 280 | ); 281 | _acrylicController.SetSystemBackdropConfiguration(_configurationSource); 282 | 283 | _currentBackdropType = useThin ? BackdropType.AcrylicThin : BackdropType.AcrylicBase; 284 | return _currentBackdropType; 285 | } 286 | 287 | private void CleanupSystemBackdrop() { 288 | if (_acrylicController != null) { 289 | _acrylicController.Dispose(); 290 | _acrylicController = null; 291 | } 292 | 293 | _window.SystemBackdrop = null; 294 | } 295 | 296 | private void CenterOnScreen() { 297 | var displayArea = DisplayArea.GetFromWindowId(_appWindow.Id, DisplayAreaFallback.Primary); 298 | var centerX = (displayArea.WorkArea.Width - _appWindow.Size.Width) / 2; 299 | var centerY = (displayArea.WorkArea.Height - _appWindow.Size.Height) / 2; 300 | _appWindow.Move(new PointInt32(centerX, centerY)); 301 | } 302 | 303 | private void ExtendContentIntoTitleBar() { 304 | if (_appWindow == null) return; 305 | 306 | var titleBar = _appWindow.TitleBar; 307 | titleBar.ExtendsContentIntoTitleBar = true; 308 | titleBar.ButtonBackgroundColor = Colors.Transparent; 309 | titleBar.ButtonInactiveBackgroundColor = Colors.Transparent; 310 | 311 | _window.SetTitleBar(null); 312 | } 313 | 314 | private void UpdateTitleBarColors() { 315 | var titleBar = _appWindow.TitleBar; 316 | var isDarkMode = (_window.Content as FrameworkElement)?.ActualTheme == ElementTheme.Dark; 317 | 318 | titleBar.ButtonForegroundColor = isDarkMode ? Colors.White : Colors.Black; 319 | } 320 | 321 | private int WindowSubClass(IntPtr hWnd, uint uMsg, IntPtr wParam, IntPtr lParam, IntPtr uIdSubclass, uint dwRefData) { 322 | if (uMsg == WM_GETMINMAXINFO) { 323 | MINMAXINFO mmi = (MINMAXINFO)Marshal.PtrToStructure(lParam, typeof(MINMAXINFO)); 324 | mmi.ptMinTrackSize.X = _minWidth; 325 | mmi.ptMinTrackSize.Y = _minHeight; 326 | 327 | Marshal.StructureToPtr(mmi, lParam, false); 328 | return 0; 329 | } 330 | return DefSubclassProc(hWnd, uMsg, wParam, lParam); 331 | } 332 | 333 | private class WindowsSystemDispatcherQueueHelper { 334 | [DllImport("CoreMessaging.dll")] 335 | private static extern int CreateDispatcherQueueController( 336 | DispatcherQueueOptions options, 337 | out IntPtr dispatcherQueueController 338 | ); 339 | 340 | private IntPtr m_dispatcherQueueController = IntPtr.Zero; 341 | 342 | public void EnsureWindowsSystemDispatcherQueueController() { 343 | if (m_dispatcherQueueController == IntPtr.Zero) { 344 | DispatcherQueueOptions options; 345 | options.dwSize = Marshal.SizeOf(typeof(DispatcherQueueOptions)); 346 | options.threadType = 2; 347 | options.apartmentType = 0; 348 | 349 | CreateDispatcherQueueController(options, out m_dispatcherQueueController); 350 | } 351 | } 352 | } 353 | 354 | [StructLayout(LayoutKind.Sequential)] 355 | private struct DispatcherQueueOptions { 356 | public int dwSize; 357 | public int threadType; 358 | public int apartmentType; 359 | } 360 | } 361 | } -------------------------------------------------------------------------------- /AppGroup/app.manifest: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | true 17 | 18 | 19 | -------------------------------------------------------------------------------- /AppGroup/default_preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iandiv/AppGroup/ca470b5283a54cd02ba7c1a54a79b72c87034c05/AppGroup/default_preview.png -------------------------------------------------------------------------------- /AppGroupBackground/AppGroupBackground.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | WinExe 5 | net8.0 6 | enable 7 | enable 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /AppGroupBackground/NativeMethods.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Runtime.InteropServices; 5 | using System.Text; 6 | using System.Threading.Tasks; 7 | 8 | namespace AppGroupBackground 9 | { 10 | public static class NativeMethods { 11 | // Constants 12 | public const int SW_HIDE = 0; 13 | public const int SW_SHOW = 5; 14 | public const int SW_RESTORE = 9; 15 | public const int WM_APP = 0x8000; 16 | public const int WM_TRAYICON = WM_APP + 1; 17 | public const int WM_LBUTTONDBLCLK = 0x0203; 18 | public const int WM_RBUTTONUP = 0x0205; 19 | public const int WM_COMMAND = 0x0111; 20 | public const int NIM_ADD = 0x00000000; 21 | public const int NIM_DELETE = 0x00000002; 22 | public const int NIF_MESSAGE = 0x00000001; 23 | public const int NIF_ICON = 0x00000002; 24 | public const int NIF_TIP = 0x00000004; 25 | public const int TPM_RIGHTBUTTON = 0x0002; 26 | public const int ID_SHOW = 1000; 27 | public const int ID_EXIT = 1001; 28 | public const uint IMAGE_ICON = 1; 29 | public const uint LR_LOADFROMFILE = 0x00000010; 30 | public const int WM_CLOSE = 0x0010; 31 | 32 | // Delegate for window procedure 33 | public delegate IntPtr WndProcDelegate(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam); 34 | 35 | // Structs 36 | [StructLayout(LayoutKind.Sequential)] 37 | public struct NOTIFYICONDATA { 38 | public int cbSize; 39 | public IntPtr hWnd; 40 | public int uID; 41 | public int uFlags; 42 | public int uCallbackMessage; 43 | public IntPtr hIcon; 44 | [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 128)] 45 | public string szTip; 46 | } 47 | 48 | [StructLayout(LayoutKind.Sequential)] 49 | public struct POINT { 50 | public int X; 51 | public int Y; 52 | } 53 | 54 | public struct MSG { 55 | public IntPtr hwnd; 56 | public uint message; 57 | public IntPtr wParam; 58 | public IntPtr lParam; 59 | public uint time; 60 | public POINT pt; 61 | } 62 | 63 | [StructLayout(LayoutKind.Sequential)] 64 | public struct WNDCLASSEX { 65 | public uint cbSize; 66 | public uint style; 67 | public IntPtr lpfnWndProc; 68 | public int cbClsExtra; 69 | public int cbWndExtra; 70 | public IntPtr hInstance; 71 | public IntPtr hIcon; 72 | public IntPtr hCursor; 73 | public IntPtr hbrBackground; 74 | public string lpszMenuName; 75 | public string lpszClassName; 76 | public IntPtr hIconSm; 77 | } 78 | 79 | // P/Invoke definitions 80 | [DllImport("user32.dll")] 81 | public static extern bool EnumWindows(EnumWindowsProc enumProc, IntPtr lParam); 82 | 83 | [DllImport("user32.dll")] 84 | public static extern int GetWindowText(IntPtr hWnd, StringBuilder lpString, int nMaxCount); 85 | 86 | [DllImport("user32.dll")] 87 | public static extern int GetWindowTextLength(IntPtr hWnd); 88 | 89 | public delegate bool EnumWindowsProc(IntPtr hWnd, IntPtr lParam); 90 | 91 | [DllImport("user32.dll", SetLastError = true)] 92 | public static extern IntPtr FindWindow(string lpClassName, string lpWindowName); 93 | 94 | [DllImport("user32.dll")] 95 | public static extern bool ShowWindow(IntPtr hWnd, int nCmdShow); 96 | 97 | [DllImport("user32.dll")] 98 | public static extern bool SetForegroundWindow(IntPtr hWnd); 99 | 100 | [DllImport("kernel32.dll")] 101 | public static extern IntPtr GetConsoleWindow(); 102 | 103 | [DllImport("user32.dll")] 104 | public static extern IntPtr CreateWindowEx(uint dwExStyle, string lpClassName, string lpWindowName, 105 | uint dwStyle, int x, int y, int nWidth, int nHeight, IntPtr hWndParent, IntPtr hMenu, 106 | IntPtr hInstance, IntPtr lpParam); 107 | 108 | [DllImport("user32.dll")] 109 | public static extern bool DestroyWindow(IntPtr hWnd); 110 | 111 | [DllImport("user32.dll")] 112 | public static extern IntPtr DefWindowProc(IntPtr hWnd, uint uMsg, IntPtr wParam, IntPtr lParam); 113 | 114 | [DllImport("user32.dll")] 115 | public static extern bool GetMessage(out MSG lpMsg, IntPtr hWnd, uint wMsgFilterMin, uint wMsgFilterMax); 116 | 117 | [DllImport("user32.dll")] 118 | public static extern bool TranslateMessage([In] ref MSG lpMsg); 119 | 120 | [DllImport("user32.dll")] 121 | public static extern IntPtr DispatchMessage([In] ref MSG lpMsg); 122 | 123 | [DllImport("shell32.dll")] 124 | public static extern bool Shell_NotifyIcon(int dwMessage, [In] ref NOTIFYICONDATA pnid); 125 | 126 | [DllImport("user32.dll")] 127 | public static extern bool GetCursorPos(out POINT lpPoint); 128 | 129 | [DllImport("user32.dll")] 130 | public static extern IntPtr CreatePopupMenu(); 131 | 132 | [DllImport("user32.dll")] 133 | public static extern bool AppendMenu(IntPtr hMenu, uint uFlags, uint uIDNewItem, string lpNewItem); 134 | 135 | [DllImport("user32.dll")] 136 | public static extern bool DestroyMenu(IntPtr hMenu); 137 | 138 | [DllImport("user32.dll")] 139 | public static extern int TrackPopupMenuEx(IntPtr hMenu, uint fuFlags, int x, int y, IntPtr hwnd, IntPtr lptpm); 140 | 141 | [DllImport("user32.dll")] 142 | public static extern ushort RegisterClassEx([In] ref WNDCLASSEX lpwcx); 143 | 144 | [DllImport("user32.dll")] 145 | public static extern IntPtr LoadCursor(IntPtr hInstance, int lpCursorName); 146 | 147 | [DllImport("kernel32.dll")] 148 | public static extern IntPtr GetModuleHandle(string lpModuleName); 149 | 150 | [DllImport("user32.dll", CharSet = CharSet.Auto)] 151 | public static extern IntPtr LoadImage(IntPtr hinst, string lpszName, uint uType, int cxDesired, int cyDesired, uint fuLoad); 152 | 153 | [DllImport("user32.dll")] 154 | public static extern bool PostMessage(IntPtr hWnd, uint Msg, IntPtr wParam, IntPtr lParam); 155 | 156 | [DllImport("user32.dll", SetLastError = true)] 157 | public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint processId); 158 | } 159 | 160 | } 161 | -------------------------------------------------------------------------------- /AppGroupBackground/Program.cs: -------------------------------------------------------------------------------- 1 | using AppGroupBackground; 2 | using System.Diagnostics; 3 | using System.Runtime.InteropServices; 4 | using System.Text; 5 | using System.Text.Json; 6 | using System.Text.RegularExpressions; 7 | 8 | namespace BackgroundClient { 9 | internal class Program { 10 | // Use NativeMethods class for P/Invoke definitions 11 | private static Dictionary activeGroups; 12 | private static CancellationTokenSource cancellationTokenSource; 13 | 14 | // Constants for the system tray 15 | private static IntPtr windowHandle; 16 | private static IntPtr hIcon; 17 | private static IntPtr hMenu; 18 | 19 | // Add this to hide the console window 20 | private static FileSystemWatcher fileWatcher; 21 | private static object _fileChangeLock = new object(); 22 | private static DateTime _lastFileChangeTime = DateTime.MinValue; 23 | private static readonly TimeSpan _debounceInterval = TimeSpan.FromSeconds(1); 24 | private static bool _fileChangeHandlingInProgress = false; 25 | private static HashSet _previousGroupNames = new HashSet(); 26 | 27 | static void Main(string[] args) { 28 | // Create mutex to ensure only one instance runs 29 | bool createdNew; 30 | using (var mutex = new Mutex(true, "AppGroupBackgroundClientMutex", out createdNew)) { 31 | if (!createdNew) { 32 | return; 33 | } 34 | 35 | // Hide console window 36 | IntPtr consoleWindow = NativeMethods.GetConsoleWindow(); 37 | if (consoleWindow != IntPtr.Zero) { 38 | NativeMethods.ShowWindow(consoleWindow, NativeMethods.SW_HIDE); 39 | } 40 | 41 | // Initialize cancellation token source 42 | cancellationTokenSource = new CancellationTokenSource(); 43 | 44 | // Initialize system tray with native API 45 | InitializeSystemTray(); 46 | SetupFileWatcher(); 47 | 48 | Task.Run(() => PreloadPopupWindows()); 49 | Task.Run(() => MonitorGroupWindows(cancellationTokenSource.Token)); 50 | 51 | RunMessageLoop(); 52 | } 53 | } 54 | 55 | #region System Tray Methods 56 | private static void InitializeSystemTray() { 57 | // Create a hidden window for message processing 58 | NativeMethods.WndProcDelegate wndProcDelegate = new NativeMethods.WndProcDelegate(WndProc); 59 | 60 | var wndClass = new NativeMethods.WNDCLASSEX { 61 | cbSize = (uint)Marshal.SizeOf(typeof(NativeMethods.WNDCLASSEX)), 62 | style = 0, 63 | lpfnWndProc = Marshal.GetFunctionPointerForDelegate(wndProcDelegate), 64 | cbClsExtra = 0, 65 | cbWndExtra = 0, 66 | hInstance = NativeMethods.GetModuleHandle(null), 67 | hIcon = IntPtr.Zero, 68 | hCursor = NativeMethods.LoadCursor(IntPtr.Zero, 32512), // IDC_ARROW 69 | hbrBackground = IntPtr.Zero, 70 | lpszMenuName = null, 71 | lpszClassName = "BackgroundClientTrayWndClass", 72 | hIconSm = IntPtr.Zero 73 | }; 74 | 75 | NativeMethods.RegisterClassEx(ref wndClass); 76 | 77 | windowHandle = NativeMethods.CreateWindowEx( 78 | 0, 79 | "BackgroundClientTrayWndClass", 80 | "BackgroundClient Tray Window", 81 | 0, 82 | 0, 0, 0, 0, 83 | IntPtr.Zero, 84 | IntPtr.Zero, 85 | NativeMethods.GetModuleHandle(null), 86 | IntPtr.Zero); 87 | 88 | // Load custom icon from file 89 | string iconPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "AppGroup.ico"); 90 | if (File.Exists(iconPath)) { 91 | hIcon = NativeMethods.LoadImage(IntPtr.Zero, iconPath, NativeMethods.IMAGE_ICON, 16, 16, NativeMethods.LR_LOADFROMFILE); 92 | if (hIcon == IntPtr.Zero) { 93 | // Fallback to system icon if loading fails 94 | hIcon = NativeMethods.LoadImage(IntPtr.Zero, "#32516", NativeMethods.IMAGE_ICON, 16, 16, 0); // IDI_APPLICATION 95 | } 96 | } 97 | else { 98 | // Fallback to system icon if file not found 99 | hIcon = NativeMethods.LoadImage(IntPtr.Zero, "#32516", NativeMethods.IMAGE_ICON, 16, 16, 0); // IDI_APPLICATION 100 | Debug.WriteLine($"Icon file not found at: {iconPath}, using system icon"); 101 | } 102 | 103 | // Create the tray icon 104 | var notifyIconData = new NativeMethods.NOTIFYICONDATA { 105 | cbSize = Marshal.SizeOf(typeof(NativeMethods.NOTIFYICONDATA)), 106 | hWnd = windowHandle, 107 | uID = 1, 108 | uFlags = NativeMethods.NIF_MESSAGE | NativeMethods.NIF_ICON | NativeMethods.NIF_TIP, 109 | uCallbackMessage = NativeMethods.WM_TRAYICON, 110 | hIcon = hIcon, 111 | szTip = "App Group" 112 | }; 113 | 114 | NativeMethods.Shell_NotifyIcon(NativeMethods.NIM_ADD, ref notifyIconData); 115 | 116 | // Create popup menu 117 | hMenu = NativeMethods.CreatePopupMenu(); 118 | NativeMethods.AppendMenu(hMenu, 0, NativeMethods.ID_SHOW, "Show"); 119 | NativeMethods.AppendMenu(hMenu, 0, NativeMethods.ID_EXIT, "Exit"); 120 | } 121 | 122 | private static void RunMessageLoop() { 123 | NativeMethods.MSG msg; 124 | while (NativeMethods.GetMessage(out msg, IntPtr.Zero, 0, 0)) { 125 | NativeMethods.TranslateMessage(ref msg); 126 | NativeMethods.DispatchMessage(ref msg); 127 | } 128 | } 129 | 130 | private static IntPtr WndProc(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam) { 131 | if (msg == NativeMethods.WM_TRAYICON && wParam.ToInt32() == 1) { 132 | if (lParam.ToInt32() == NativeMethods.WM_LBUTTONDBLCLK) { 133 | // Double click - show AppGroup 134 | ShowAppGroup(); 135 | return IntPtr.Zero; 136 | } 137 | else if (lParam.ToInt32() == NativeMethods.WM_RBUTTONUP) { 138 | // Right click - show context menu 139 | ShowContextMenu(); 140 | return IntPtr.Zero; 141 | } 142 | } 143 | 144 | // Handle menu commands 145 | if (msg == NativeMethods.WM_COMMAND) { 146 | int menuId = wParam.ToInt32() & 0xFFFF; // Extract the lower 16 bits which contain the menu ID 147 | Debug.WriteLine($"Received WM_COMMAND with ID: {menuId}"); 148 | 149 | if (menuId == NativeMethods.ID_SHOW) { 150 | ShowAppGroup(); 151 | return IntPtr.Zero; 152 | } 153 | else if (menuId == NativeMethods.ID_EXIT) { 154 | KillAppGroup(); 155 | return IntPtr.Zero; 156 | } 157 | } 158 | 159 | return NativeMethods.DefWindowProc(hWnd, msg, wParam, lParam); 160 | } 161 | 162 | private static void ShowContextMenu() { 163 | NativeMethods.POINT pt; 164 | NativeMethods.GetCursorPos(out pt); 165 | 166 | // Need to bring the message window to the foreground otherwise the menu won't disappear properly 167 | NativeMethods.SetForegroundWindow(windowHandle); 168 | 169 | // Use TrackPopupMenuEx instead of TrackPopupMenu for better handling 170 | NativeMethods.TrackPopupMenuEx( 171 | hMenu, 172 | NativeMethods.TPM_RIGHTBUTTON, 173 | pt.X, 174 | pt.Y, 175 | windowHandle, 176 | IntPtr.Zero); 177 | 178 | // Send a dummy message to dismiss the menu when clicking elsewhere 179 | NativeMethods.PostMessage(windowHandle, 0, IntPtr.Zero, IntPtr.Zero); 180 | } 181 | #endregion 182 | 183 | #region File Watcher Methods 184 | private static void SetupFileWatcher() { 185 | string jsonFilePath = GetDefaultConfigPath(); 186 | if (File.Exists(jsonFilePath)) { 187 | fileWatcher = new FileSystemWatcher(); 188 | fileWatcher.Path = Path.GetDirectoryName(jsonFilePath); 189 | fileWatcher.Filter = Path.GetFileName(jsonFilePath); 190 | fileWatcher.NotifyFilter = NotifyFilters.LastWrite; 191 | fileWatcher.Changed += OnJsonFileChanged; 192 | fileWatcher.EnableRaisingEvents = true; 193 | Debug.WriteLine($"File watcher set up for: {jsonFilePath}"); 194 | 195 | _previousGroupNames = ExtractGroupNames(jsonFilePath); 196 | } 197 | else { 198 | Debug.WriteLine($"JSON file not found at path: {jsonFilePath}"); 199 | } 200 | } 201 | 202 | private static void OnJsonFileChanged(object sender, FileSystemEventArgs e) { 203 | lock (_fileChangeLock) { 204 | DateTime now = DateTime.Now; 205 | 206 | if (_fileChangeHandlingInProgress) { 207 | _lastFileChangeTime = now; 208 | Debug.WriteLine("File change detected while another is being processed - updated timestamp"); 209 | return; 210 | } 211 | 212 | if ((now - _lastFileChangeTime) < _debounceInterval) { 213 | _lastFileChangeTime = now; 214 | Debug.WriteLine("File change debounced - will handle after cooldown period"); 215 | 216 | if (!_fileChangeHandlingInProgress) { 217 | _fileChangeHandlingInProgress = true; 218 | Task.Run(async () => { 219 | await DebouncedHandleFileChange(); 220 | }); 221 | } 222 | return; 223 | } 224 | 225 | _lastFileChangeTime = now; 226 | _fileChangeHandlingInProgress = true; 227 | Debug.WriteLine($"File change detected, handling immediately: {e.FullPath}"); 228 | Task.Run(async () => { 229 | await DebouncedHandleFileChange(); 230 | }); 231 | } 232 | } 233 | 234 | private static async Task DebouncedHandleFileChange() { 235 | try { 236 | await Task.Delay(_debounceInterval); 237 | 238 | DateTime lastChangeTime; 239 | lock (_fileChangeLock) { 240 | lastChangeTime = _lastFileChangeTime; 241 | } 242 | 243 | while ((DateTime.Now - lastChangeTime) < _debounceInterval) { 244 | await Task.Delay(_debounceInterval); 245 | lock (_fileChangeLock) { 246 | lastChangeTime = _lastFileChangeTime; 247 | } 248 | } 249 | 250 | Debug.WriteLine("Debounce period completed, handling file change now"); 251 | 252 | string jsonFilePath = GetDefaultConfigPath(); 253 | if (File.Exists(jsonFilePath)) { 254 | await Task.Delay(500); 255 | 256 | HashSet currentGroupNames = ExtractGroupNames(jsonFilePath); 257 | if (!_previousGroupNames.SetEquals(currentGroupNames)) { 258 | KillAllGroupWindows(); 259 | _previousGroupNames = currentGroupNames; 260 | } 261 | } 262 | } 263 | catch (Exception ex) { 264 | Debug.WriteLine($"Error in DebouncedHandleFileChange: {ex.Message}"); 265 | } 266 | finally { 267 | lock (_fileChangeLock) { 268 | _fileChangeHandlingInProgress = false; 269 | } 270 | } 271 | } 272 | 273 | private static HashSet ExtractGroupNames(string filePath) { 274 | HashSet groupNames = new HashSet(); 275 | string fileContent = File.ReadAllText(filePath); 276 | MatchCollection matches = Regex.Matches(fileContent, @"""groupName"":\s*""([^""]+)"""); 277 | 278 | foreach (Match match in matches) { 279 | groupNames.Add(match.Groups[1].Value); 280 | } 281 | 282 | return groupNames; 283 | } 284 | #endregion 285 | 286 | #region Group Window Methods 287 | private static void KillAllGroupWindows() { 288 | try { 289 | Debug.WriteLine("Killing all group windows before reload"); 290 | 291 | // First, kill groups from the currently loaded activeGroups 292 | if (activeGroups != null) { 293 | foreach (var group in activeGroups.Values) { 294 | IntPtr hWnd = NativeMethods.FindWindow(null, group.groupName); 295 | if (hWnd != IntPtr.Zero) { 296 | Debug.WriteLine($"Found window for group '{group.groupName}', killing process"); 297 | 298 | // Get the process ID from the window handle 299 | uint processId; 300 | NativeMethods.GetWindowThreadProcessId(hWnd, out processId); 301 | 302 | if (processId > 0) { 303 | try { 304 | // Open and kill the process 305 | Process process = Process.GetProcessById((int)processId); 306 | process.Kill(); 307 | Debug.WriteLine($"Killed process for group '{group.groupName}' with ID: {processId}"); 308 | } 309 | catch (Exception ex) { 310 | Debug.WriteLine($"Error killing process: {ex.Message}"); 311 | } 312 | } 313 | } 314 | } 315 | } 316 | 317 | // Then reload the new groups after killing the old ones 318 | string jsonFilePath = GetDefaultConfigPath(); 319 | if (File.Exists(jsonFilePath)) { 320 | string jsonContent = File.ReadAllText(jsonFilePath); 321 | activeGroups = JsonSerializer.Deserialize>(jsonContent); 322 | Debug.WriteLine($"Reloaded groups from config file: {activeGroups?.Count ?? 0} groups"); 323 | } 324 | } 325 | catch (Exception ex) { 326 | Debug.WriteLine($"Error in KillAllGroupWindows: {ex.Message}"); 327 | } 328 | } 329 | 330 | private static void PreloadPopupWindows() { 331 | try { 332 | // Load and parse the JSON file 333 | string jsonFilePath = GetDefaultConfigPath(); 334 | if (File.Exists(jsonFilePath)) { 335 | string jsonContent = File.ReadAllText(jsonFilePath); 336 | activeGroups = JsonSerializer.Deserialize>(jsonContent); 337 | 338 | if (activeGroups != null) { 339 | var tasks = new List(); 340 | foreach (var group in activeGroups.Values) { 341 | // Check if this group is already running before launching 342 | IntPtr hWnd = NativeMethods.FindWindow(null, group.groupName); 343 | if (hWnd != IntPtr.Zero) { 344 | Debug.WriteLine($"Group '{group.groupName}' already running, skipping preload."); 345 | continue; 346 | } 347 | 348 | tasks.Add(Task.Run(() => LaunchAppGroupInSeparateProcess(group.groupName))); 349 | } 350 | 351 | // Wait for all tasks to complete 352 | Task.WhenAll(tasks).Wait(); 353 | } 354 | } 355 | else { 356 | Debug.WriteLine($"JSON file not found at path: {jsonFilePath}"); 357 | } 358 | } 359 | catch (Exception ex) { 360 | Debug.WriteLine($"Exception in PreloadPopupWindows: {ex.Message}"); 361 | } 362 | } 363 | 364 | private static async Task MonitorGroupWindows(CancellationToken cancellationToken) { 365 | // Wait a bit for initial launching to complete 366 | await Task.Delay(5000, cancellationToken); 367 | 368 | // Dictionary to track process IDs for each group 369 | Dictionary groupProcessIds = new Dictionary(); 370 | // Dictionary to track launch attempts to prevent continuous launch loops 371 | Dictionary lastLaunchAttempts = new Dictionary(); 372 | // Minimum time between launch attempts (15 seconds) 373 | TimeSpan minTimeBetweenLaunches = TimeSpan.FromSeconds(15); 374 | 375 | while (!cancellationToken.IsCancellationRequested) { 376 | try { 377 | if (activeGroups != null) { 378 | var tasks = new List(); 379 | foreach (var group in activeGroups.Values) { 380 | bool isRunning = false; 381 | 382 | // First check: Look for window by exact name 383 | IntPtr hWnd = NativeMethods.FindWindow(null, group.groupName); 384 | if (hWnd != IntPtr.Zero) { 385 | isRunning = true; 386 | } 387 | // Second check: Look for windows that contain the group name 388 | else if (IsWindowWithPartialTitleRunning(group.groupName)) { 389 | isRunning = true; 390 | } 391 | // Third check: Check if we have a process ID and if it's still running 392 | else if (groupProcessIds.TryGetValue(group.groupName, out int processId)) { 393 | try { 394 | var process = Process.GetProcessById(processId); 395 | if (!process.HasExited) { 396 | isRunning = true; 397 | } 398 | } 399 | catch (ArgumentException) { 400 | // Process no longer exists 401 | groupProcessIds.Remove(group.groupName); 402 | } 403 | } 404 | 405 | if (!isRunning) { 406 | // Check if we've attempted to launch recently to avoid rapid relaunching 407 | bool canLaunch = true; 408 | if (lastLaunchAttempts.TryGetValue(group.groupName, out DateTime lastLaunch)) { 409 | if (DateTime.Now - lastLaunch < minTimeBetweenLaunches) { 410 | canLaunch = false; 411 | Debug.WriteLine($"Skipping launch for '{group.groupName}' - too soon since last attempt"); 412 | } 413 | } 414 | 415 | if (canLaunch) { 416 | Debug.WriteLine($"Group '{group.groupName}' not running, relaunching..."); 417 | lastLaunchAttempts[group.groupName] = DateTime.Now; 418 | 419 | tasks.Add(Task.Run(() => { 420 | int? newProcessId = LaunchAppGroupInSeparateProcess(group.groupName); 421 | if (newProcessId.HasValue) { 422 | groupProcessIds[group.groupName] = newProcessId.Value; 423 | } 424 | })); 425 | } 426 | } 427 | } 428 | await Task.WhenAll(tasks); 429 | } 430 | await Task.Delay(3000, cancellationToken); 431 | } 432 | catch (OperationCanceledException) { 433 | break; 434 | } 435 | catch (Exception ex) { 436 | Debug.WriteLine($"Exception in MonitorGroupWindows: {ex.Message}"); 437 | await Task.Delay(2000, cancellationToken); 438 | } 439 | } 440 | } 441 | 442 | // Helper method to check if any window contains the group name in its title 443 | private static bool IsWindowWithPartialTitleRunning(string partialTitle) { 444 | bool found = false; 445 | NativeMethods.EnumWindows((hWnd, lParam) => { 446 | int textLength = NativeMethods.GetWindowTextLength(hWnd); 447 | if (textLength > 0) { 448 | StringBuilder sb = new StringBuilder(textLength + 1); 449 | NativeMethods.GetWindowText(hWnd, sb, sb.Capacity); 450 | string windowTitle = sb.ToString(); 451 | if (windowTitle.Contains(partialTitle)) { 452 | found = true; 453 | return false; // Stop enumeration 454 | } 455 | } 456 | return true; // Continue enumeration 457 | }, IntPtr.Zero); 458 | return found; 459 | } 460 | 461 | // Modified to return the process ID if launched successfully 462 | private static int? LaunchAppGroupInSeparateProcess(string groupName) { 463 | try { 464 | string executableDir = AppDomain.CurrentDomain.BaseDirectory; 465 | string shortcutPath = Path.Combine(executableDir, "Groups", groupName, $"{groupName}.lnk"); 466 | 467 | // Check if the shortcut exists 468 | if (File.Exists(shortcutPath)) { 469 | // Use ProcessStartInfo with UseShellExecute=true to handle .lnk files 470 | // This is safer than using ShellExecute directly 471 | ProcessStartInfo psi = new ProcessStartInfo { 472 | FileName = shortcutPath, 473 | Arguments = "--silent", 474 | UseShellExecute = true, // Required for .lnk files 475 | WindowStyle = ProcessWindowStyle.Hidden 476 | }; 477 | 478 | Process process = Process.Start(psi); 479 | Debug.WriteLine($"Launched shortcut for group: {groupName} with --silent (PID: {process.Id})"); 480 | return process.Id; 481 | } 482 | return null; 483 | } 484 | catch (Exception ex) { 485 | Debug.WriteLine($"Error launching application for group {groupName}: {ex.Message}"); 486 | return null; 487 | } 488 | } 489 | #endregion 490 | 491 | #region AppGroup Methods 492 | private static void ShowAppGroup() { 493 | try { 494 | // First check if AppGroup is already running by window title 495 | IntPtr appGroupWindow = NativeMethods.FindWindow(null, "AppGroup"); 496 | 497 | if (appGroupWindow != IntPtr.Zero) { 498 | // If window exists, make sure it's visible and bring it to front 499 | Debug.WriteLine("AppGroup.exe window found, bringing to front"); 500 | NativeMethods.ShowWindow(appGroupWindow, NativeMethods.SW_RESTORE); 501 | NativeMethods.SetForegroundWindow(appGroupWindow); 502 | return; // Exit early - no need to launch a new instance 503 | } 504 | 505 | // Next check if the process is running even if window is not found 506 | Process[] existingProcesses = Process.GetProcessesByName("AppGroup"); 507 | if (existingProcesses.Length > 0) { 508 | Debug.WriteLine("AppGroup.exe process found, attempting to show window"); 509 | foreach (var process in existingProcesses) { 510 | // Try to bring its main window to front if it has one 511 | if (process.MainWindowHandle != IntPtr.Zero) { 512 | NativeMethods.ShowWindow(process.MainWindowHandle, NativeMethods.SW_RESTORE); 513 | NativeMethods.SetForegroundWindow(process.MainWindowHandle); 514 | return; // Exit if we successfully showed a window 515 | } 516 | } 517 | 518 | // If we got here, there's a process but no window - kill and restart 519 | foreach (var process in existingProcesses) { 520 | try { 521 | process.Kill(); 522 | Debug.WriteLine($"Killed existing AppGroup process with ID: {process.Id}"); 523 | } 524 | catch (Exception ex) { 525 | Debug.WriteLine($"Failed to kill process: {ex.Message}"); 526 | } 527 | } 528 | } 529 | 530 | // AppGroup is not running or we killed stuck processes, start it normally 531 | string appGroupPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "AppGroup.exe"); 532 | if (File.Exists(appGroupPath)) { 533 | ProcessStartInfo startInfo = new ProcessStartInfo(appGroupPath); 534 | startInfo.WindowStyle = ProcessWindowStyle.Normal; // Make sure it's visible 535 | Process.Start(startInfo); 536 | Debug.WriteLine("AppGroup.exe started"); 537 | } 538 | else { 539 | Debug.WriteLine($"AppGroup.exe not found at: {appGroupPath}"); 540 | } 541 | } 542 | catch (Exception ex) { 543 | Debug.WriteLine($"Error showing AppGroup: {ex.Message}"); 544 | } 545 | } 546 | 547 | private static void KillAppGroup() { 548 | try { 549 | // Find and kill all AppGroup.exe processes 550 | foreach (var process in Process.GetProcessesByName("AppGroup")) { 551 | try { 552 | process.Kill(); 553 | Debug.WriteLine($"Killed AppGroup.exe process with ID: {process.Id}"); 554 | } 555 | catch (Exception ex) { 556 | Debug.WriteLine($"Failed to kill AppGroup.exe process: {ex.Message}"); 557 | } 558 | } 559 | 560 | // Exit the background client as well 561 | ExitApplication(); 562 | } 563 | catch (Exception ex) { 564 | Debug.WriteLine($"Error killing AppGroup: {ex.Message}"); 565 | } 566 | } 567 | 568 | private static void ExitApplication() { 569 | // Remove tray icon 570 | var notifyIconData = new NativeMethods.NOTIFYICONDATA { 571 | cbSize = Marshal.SizeOf(typeof(NativeMethods.NOTIFYICONDATA)), 572 | hWnd = windowHandle, 573 | uID = 1 574 | }; 575 | 576 | NativeMethods.Shell_NotifyIcon(NativeMethods.NIM_DELETE, ref notifyIconData); 577 | 578 | // Cleanup resources 579 | NativeMethods.DestroyMenu(hMenu); 580 | NativeMethods.DestroyWindow(windowHandle); 581 | 582 | // Cancel monitoring tasks 583 | cancellationTokenSource?.Cancel(); 584 | 585 | // Exit the application 586 | Environment.Exit(0); 587 | } 588 | 589 | delegate bool ConsoleCtrlHandlerDelegate(int eventType); 590 | 591 | [DllImport("kernel32.dll")] 592 | static extern bool SetConsoleCtrlHandler(ConsoleCtrlHandlerDelegate handler, bool add); 593 | 594 | static bool ConsoleCtrlHandler(int eventType) { 595 | // Cancel monitoring tasks 596 | cancellationTokenSource?.Cancel(); 597 | 598 | // Remove tray icon 599 | var notifyIconData = new NativeMethods.NOTIFYICONDATA { 600 | cbSize = Marshal.SizeOf(typeof(NativeMethods.NOTIFYICONDATA)), 601 | hWnd = windowHandle, 602 | uID = 1 603 | }; 604 | 605 | NativeMethods.Shell_NotifyIcon(NativeMethods.NIM_DELETE, ref notifyIconData); 606 | 607 | return false; 608 | } 609 | #endregion 610 | 611 | #region Helper Methods 612 | private static string GetDefaultConfigPath() { 613 | string appDataPath = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); 614 | string appGroupPath = Path.Combine(appDataPath, "AppGroup"); 615 | string configFilePath = Path.Combine(appGroupPath, "appgroups.json"); 616 | 617 | if (!Directory.Exists(appGroupPath)) { 618 | Directory.CreateDirectory(appGroupPath); 619 | } 620 | 621 | if (!File.Exists(configFilePath)) { 622 | string emptyJson = "{}"; 623 | File.WriteAllText(configFilePath, emptyJson); 624 | } 625 | 626 | return configFilePath; 627 | } 628 | #endregion 629 | } 630 | 631 | public class GroupData { 632 | public string groupName { get; set; } 633 | } 634 | } 635 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 IanDiv 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | ![AppGroup](https://github.com/user-attachments/assets/169e1383-fe84-4f6b-997e-75ee218abe0c) 3 | 4 | 5 | # App Group 6 | 7 | App Group lets you **organize, customize, and launch** your apps. Create groups, set custom icons, and access your apps faster. 8 | 9 | ## Table of Contents 10 | 11 | 12 | - [Key Features](#key-features) 13 | - [Group Management](#group-management) 14 | - [Appearance & Customization](#appearance--customization) 15 | - [App & Shortcut Support](#app--shortcut-support) 16 | - [Import/Export](#importexport) 17 | - [Others](#others) 18 | - [Installation](#installation) 19 | - [Screenshots](#screenshots) 20 | - [Video Demo](#video-demo) 21 | - [How to Use](#how-to-use) 22 | - [Support](#support) 23 | - [License](#license) 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | --- 32 | 33 | ## Key Features 34 | 35 | ### Group Management 36 | - **Create, edit, delete** , and **duplicate** groups 37 | - **"Edit this Group"** option in jumplist 38 | - **"Launch All"** option in jumplist 39 | 40 | ### Appearance & Customization 41 | - **Custom icons**: Use a **Single icon** or a **Grid icon** 42 | - **Accent-colored backgrounds** for groups 43 | - **Show or hide group names** for a clean look 44 | - **Dark Mode & Light Mode** experience 45 | - **Adjust grid columns** 46 | - **Drag & Drop** to reorder apps instantly 47 | - **Supports .exe files** as custom icons 48 | - **Custom tooltips and launch arguments** 49 | 50 | ### App & Shortcut Support 51 | - **Supports UWP & PWA apps** via shortcuts 52 | - **Support .lnk shortcuts** without the arrow (if possible) 53 | - **Run apps as Admin** 54 | 55 | ### Import/Export 56 | - **Supports .agz (AppGroupZip)** file import/export 57 | 58 | 59 | ### Others 60 | - **Persistent In-Memory Caching** for faster performance 61 | - **Supports different taskbar positions**: **Top**, **Bottom**, **Left**, **Right** 62 | 63 | 64 | ## Installation 65 | 66 | 1. [Download the latest version](https://github.com/iandiv/AppGroup/releases) from the **Releases** page. 67 | 2. Extract the downloaded `.zip` file. 68 | 3. Run **AppGroup.exe** — no setup needed! 69 | 70 | 71 | ## Screenshots 72 | 73 | 74 | explorer_iaflNp0ULV explorer_diGSIGlrYFexplorer_R1L5xazeSe 75 | 76 | ## Video Demo 77 | 78 | 79 | 80 | https://github.com/user-attachments/assets/6d37560f-16ea-45a9-b8b2-9d94bced0ff2 81 | 82 | 83 | 84 | ## How to Use 85 | 86 | 1. **Create a Group** 87 | - Click **“+”** to create a group 88 | - Set a **Group Name** 89 | 90 | 2. **Add Apps** 91 | - Click **“+”** or **drag & drop** apps into the group 92 | 93 | 3. **Customize Your Group** 94 | - Enable **Show Header** if needed 95 | - Adjust **grid columns** for the perfect layout 96 | - Set an **icon style** 97 | - **Regular** - Choose an icon from a directory 98 | - **Grid** - Create grid icon from selected applications 99 | 100 | 4. **Pin a Group to the Taskbar** 101 | - Open **Groups Folder** and find the group folder 102 | - Inside, you'll find a **shortcut file** 103 | - **Right-click → Pin to Taskbar** 104 | 105 | **OR** 106 | 107 | - Click the **three-dot menu (···)** in the main window 108 | - Select **Open File Location** 109 | - This opens the folder containing the shortcut 110 | - **Right-click → Pin to Taskbar** 111 | 112 | 113 | 114 | 115 | ## Support 116 | AppGroup is actively maintained, and your support helps keep it improving. If you find this tool helpful, consider donating to support its development. Your contribution ensures new updates and improvements: 117 | 118 | **[🍵 Donate on Ko-fi ](https://ko-fi.com/iandiv/tip)** 119 | 120 | 121 | 122 | 123 | explorer_iaflNp0ULV 124 | 125 | 126 | 127 | 128 | ## License 129 | This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. 130 | 131 | --------------------------------------------------------------------------------