├── .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 |
239 |
240 |
241 |
242 |
243 |
244 |
245 |
246 |
247 |
--------------------------------------------------------------------------------
/AppGroup/IconCache.cs:
--------------------------------------------------------------------------------
1 |
2 | using System;
3 | using System.Collections.Generic;
4 | using System.Diagnostics;
5 | using System.IO;
6 | using System.Text.Json;
7 | using System.Threading.Tasks;
8 | using Microsoft.UI.Dispatching;
9 | using Microsoft.UI.Xaml.Media.Imaging;
10 | namespace AppGroup
11 | {
12 |
13 | public static class IconCache {
14 | public static Dictionary _iconCache = new Dictionary();
15 | private static readonly string CacheFilePath = GetCacheFilePath();
16 |
17 | static IconCache() {
18 | LoadIconCache();
19 | }
20 |
21 | private static string GetCacheFilePath() {
22 | string folder = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
23 | string appGroupFolder = Path.Combine(folder, "AppGroup");
24 | Directory.CreateDirectory(appGroupFolder);
25 | return Path.Combine(appGroupFolder, "icon_cache.json");
26 | }
27 |
28 | private static void LoadIconCache() {
29 | try {
30 | if (File.Exists(CacheFilePath)) {
31 | string json = File.ReadAllText(CacheFilePath);
32 | var cacheData = JsonSerializer.Deserialize>(json);
33 |
34 | if (cacheData != null) {
35 | foreach (var kvp in cacheData) {
36 | if (!string.IsNullOrEmpty(kvp.Value) && File.Exists(kvp.Value)) {
37 | _iconCache[kvp.Key] = kvp.Value;
38 | }
39 | }
40 | }
41 | Debug.WriteLine($"Cache loaded from {CacheFilePath}");
42 | }
43 | }
44 | catch (Exception ex) {
45 | Debug.WriteLine($"Failed to load cache: {ex.Message}");
46 | }
47 | }
48 |
49 | public static async Task LoadImageFromPathAsync(string filePath) {
50 | BitmapImage bitmapImage = new BitmapImage();
51 |
52 | try {
53 | using var stream = File.OpenRead(filePath);
54 | using var randomAccessStream = stream.AsRandomAccessStream();
55 | await bitmapImage.SetSourceAsync(randomAccessStream);
56 | }
57 | catch (Exception ex) {
58 | Debug.WriteLine($"Failed to load image: {ex.Message}");
59 | }
60 |
61 | return bitmapImage;
62 | }
63 | public static async Task GetIconPathAsync(string filePath) {
64 | if (string.IsNullOrEmpty(filePath)) return null;
65 |
66 | string cacheKey = ComputeFileCacheKey(filePath);
67 |
68 | if (_iconCache.TryGetValue(cacheKey, out var cachedIconPath)) {
69 | return cachedIconPath;
70 | }
71 |
72 | try {
73 | string outputDirectory = Path.Combine(
74 | Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
75 | "AppGroup",
76 | "Icons"
77 | );
78 | Directory.CreateDirectory(outputDirectory);
79 |
80 | var extractedIconPath = await IconHelper.ExtractIconAndSaveAsync(filePath, outputDirectory, TimeSpan.FromSeconds(2));
81 |
82 | if (extractedIconPath != null && File.Exists(extractedIconPath)) {
83 | _iconCache[cacheKey] = extractedIconPath;
84 | SaveIconCache();
85 | return extractedIconPath;
86 | }
87 | }
88 | catch (Exception ex) {
89 | Debug.WriteLine($"Icon extraction failed for {filePath}: {ex.Message}");
90 | }
91 |
92 | return null;
93 | }
94 | public static void SaveIconCache() {
95 | try {
96 | string json = JsonSerializer.Serialize(_iconCache, new JsonSerializerOptions { WriteIndented = true });
97 | File.WriteAllText(CacheFilePath, json);
98 | Debug.WriteLine($"Cache saved to {CacheFilePath}");
99 | }
100 | catch (Exception ex) {
101 | Debug.WriteLine($"Failed to save cache: {ex.Message}");
102 | }
103 | }
104 |
105 | public static string ComputeFileCacheKey(string filePath) {
106 | var fileInfo = new FileInfo(filePath);
107 | return $"{filePath}_{fileInfo.LastWriteTimeUtc}_{fileInfo.Length}";
108 | }
109 | }
110 |
111 | }
112 |
--------------------------------------------------------------------------------
/AppGroup/JsonConfigHelper.cs:
--------------------------------------------------------------------------------
1 | using IWshRuntimeLibrary;
2 | using System;
3 | using System.Collections.Generic;
4 | using System.Diagnostics;
5 | using System.IO;
6 | using System.Linq;
7 | using System.Text.Json;
8 | using System.Text.Json.Nodes;
9 | using System.Threading.Tasks;
10 | using File = System.IO.File;
11 |
12 | namespace AppGroup
13 | {
14 | public class JsonConfigHelper
15 | {
16 |
17 | public static int FindKeyByGroupName(string groupName) {
18 | string json = File.ReadAllText(GetDefaultConfigPath());
19 | var jsonDocument = JsonDocument.Parse(json);
20 |
21 | foreach (var property in jsonDocument.RootElement.EnumerateObject()) {
22 | if (property.Value.TryGetProperty("groupName", out JsonElement groupNameElement)) {
23 | if (groupNameElement.GetString() == groupName) {
24 | if (int.TryParse(property.Name, out int key)) {
25 | return key;
26 | }
27 | }
28 | }
29 | }
30 |
31 | throw new Exception($"No key found for groupName '{groupName}'");
32 | }
33 |
34 | public static string ReadJsonFromFile(string filePath)
35 | {
36 | try
37 | {
38 | if (!System.IO.File.Exists(filePath))
39 | {
40 | throw new FileNotFoundException($"JSON configuration file not found at: {filePath}");
41 | }
42 |
43 | return System.IO.File.ReadAllText(filePath);
44 | }
45 | catch (Exception ex)
46 | {
47 | throw new Exception($"Error reading JSON file: {ex.Message}", ex);
48 | }
49 | }
50 | public static int GetNextGroupId() {
51 | string jsonFilePath = GetDefaultConfigPath();
52 | string jsonContent = System.IO.File.Exists(jsonFilePath) ? System.IO.File.ReadAllText(jsonFilePath) : "{}";
53 | JsonNode jsonObject = JsonNode.Parse(jsonContent) ?? new JsonObject();
54 |
55 | if (jsonObject.AsObject().Any()) {
56 | int maxGroupId = jsonObject.AsObject()
57 | .Select(property => int.Parse(property.Key))
58 | .Max();
59 | return maxGroupId + 1;
60 | }
61 | else {
62 | return 1;
63 | }
64 | }
65 | public static async Task ReadJsonFromFileAsync(string filePath)
66 | {
67 | try
68 | {
69 | if (!System.IO.File.Exists(filePath))
70 | {
71 | throw new FileNotFoundException($"JSON configuration file not found at: {filePath}");
72 | }
73 |
74 | return await System.IO.File.ReadAllTextAsync(filePath);
75 | }
76 | catch (Exception ex)
77 | {
78 | throw new Exception($"Error reading JSON file: {ex.Message}", ex);
79 | }
80 | }
81 |
82 | public static string GetDefaultConfigPath(string fileName = "appgroups.json")
83 | {
84 |
85 |
86 | string localAppDataPath = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
87 |
88 | string appDataPath = Path.Combine(localAppDataPath, "AppGroup");
89 |
90 | if (!Directory.Exists(appDataPath)) {
91 | Directory.CreateDirectory(appDataPath);
92 | }
93 |
94 | return Path.Combine(appDataPath, fileName);
95 | }
96 | public static void AddGroupToJson(string filePath, int groupId, string groupName, bool groupHeader, string groupIcon, int groupCol, Dictionary paths) {
97 | try {
98 | string directory = Path.GetDirectoryName(filePath);
99 | if (!Directory.Exists(directory)) {
100 | Directory.CreateDirectory(directory);
101 | }
102 |
103 | if (!System.IO.File.Exists(filePath)) {
104 | System.IO.File.WriteAllText(filePath, "{}");
105 | }
106 | string jsonContent = ReadJsonFromFile(filePath);
107 | JsonNode jsonObject = JsonNode.Parse(jsonContent) ?? new JsonObject();
108 |
109 | JsonObject jsonPaths = new JsonObject();
110 | foreach (var path in paths) {
111 | JsonObject pathDetails = new JsonObject
112 | {
113 | { "tooltip", path.Value.tooltip },
114 | { "args", path.Value.args }
115 | };
116 | jsonPaths[path.Key] = pathDetails;
117 | }
118 |
119 | JsonObject newGroup = new JsonObject
120 | {
121 | { "groupName", groupName },
122 | { "groupHeader", groupHeader },
123 | { "groupCol", groupCol },
124 | { "groupIcon", groupIcon },
125 | { "path", jsonPaths }
126 | };
127 |
128 | jsonObject[groupId.ToString()] = newGroup;
129 |
130 | System.IO.File.WriteAllText(filePath, JsonSerializer.Serialize(jsonObject, new JsonSerializerOptions { WriteIndented = true }));
131 | }
132 | catch (Exception ex) {
133 | throw new Exception($"Error adding group to JSON file: {ex.Message}", ex);
134 | }
135 | }
136 |
137 |
138 |
139 | public static void DeleteGroupFromJson(string filePath, int groupId) {
140 | try {
141 | string jsonContent = ReadJsonFromFile(filePath);
142 | JsonNode jsonObject = JsonNode.Parse(jsonContent) ?? new JsonObject();
143 |
144 | if (!jsonObject.AsObject().ContainsKey(groupId.ToString())) {
145 | throw new KeyNotFoundException($"Group ID {groupId} not found in JSON file.");
146 | }
147 |
148 | string groupName = jsonObject[groupId.ToString()]?["groupName"]?.GetValue();
149 |
150 | if (string.IsNullOrEmpty(groupName)) {
151 | throw new InvalidOperationException($"Could not retrieve group name for Group ID {groupId}.");
152 | }
153 |
154 | jsonObject.AsObject().Remove(groupId.ToString());
155 |
156 | System.IO.File.WriteAllText(filePath, JsonSerializer.Serialize(jsonObject, new JsonSerializerOptions { WriteIndented = true }));
157 |
158 | string exeDirectory = Path.GetDirectoryName(Environment.ProcessPath);
159 | string groupsFolder = Path.Combine(exeDirectory, "Groups");
160 | string groupFolderPath = Path.Combine(groupsFolder, groupName);
161 |
162 | if (Directory.Exists(groupFolderPath)) {
163 | Directory.Delete(groupFolderPath, true);
164 | }
165 | }
166 | catch (Exception ex) {
167 | throw new Exception($"Error deleting group: {ex.Message}", ex);
168 | }
169 | }
170 |
171 |
172 | public static void DuplicateGroupInJson(string filePath, int groupId) {
173 | try {
174 | string jsonContent = ReadJsonFromFile(filePath);
175 | JsonNode jsonObject = JsonNode.Parse(jsonContent) ?? new JsonObject();
176 |
177 | if (jsonObject.AsObject().ContainsKey(groupId.ToString())) {
178 | JsonNode groupToDuplicate = jsonObject[groupId.ToString()];
179 | int newGroupId = GetNextGroupId();
180 |
181 | JsonObject duplicatedGroup = groupToDuplicate.AsObject().DeepClone() as JsonObject;
182 | string originalGroupName = duplicatedGroup["groupName"]?.GetValue() ?? "Group";
183 | string newGroupName = GetUniqueGroupName(jsonObject, $"{originalGroupName} - Copy");
184 |
185 | duplicatedGroup["groupName"] = newGroupName;
186 |
187 | string originalGroupIcon = duplicatedGroup["groupIcon"]?.GetValue() ?? string.Empty;
188 | if (!string.IsNullOrEmpty(originalGroupIcon)) {
189 | string newGroupIcon = originalGroupIcon.Replace(originalGroupName, newGroupName);
190 | duplicatedGroup["groupIcon"] = newGroupIcon;
191 | }
192 |
193 | jsonObject[newGroupId.ToString()] = duplicatedGroup;
194 |
195 | System.IO.File.WriteAllText(filePath, JsonSerializer.Serialize(jsonObject, new JsonSerializerOptions { WriteIndented = true }));
196 |
197 | string exeDirectory = Path.GetDirectoryName(Environment.ProcessPath);
198 | string groupsFolder = Path.Combine(exeDirectory, "Groups");
199 | string originalGroupFolderPath = Path.Combine(groupsFolder, originalGroupName);
200 | string newGroupFolderPath = Path.Combine(groupsFolder, newGroupName);
201 |
202 | if (Directory.Exists(originalGroupFolderPath)) {
203 | CopyDirectory(originalGroupFolderPath, newGroupFolderPath, originalGroupName, newGroupName);
204 | }
205 | }
206 | else {
207 | throw new KeyNotFoundException($"Group ID {groupId} not found in JSON file.");
208 | }
209 | }
210 | catch (Exception ex) {
211 | throw new Exception($"Error duplicating group in JSON file: {ex.Message}", ex);
212 | }
213 | }
214 |
215 | private static string GetUniqueGroupName(JsonNode jsonObject, string baseName) {
216 | string uniqueName = baseName;
217 | int counter = 2;
218 |
219 | while (true) {
220 | bool nameExists = false;
221 | foreach (var group in jsonObject.AsObject()) {
222 | if (group.Value["groupName"]?.GetValue() == uniqueName) {
223 | nameExists = true;
224 | break;
225 | }
226 | }
227 |
228 | if (!nameExists) {
229 | break;
230 | }
231 |
232 | uniqueName = $"{baseName}({counter++})";
233 | }
234 |
235 | return uniqueName;
236 | }
237 |
238 | public static async Task LaunchAll(string groupName) {
239 | try {
240 | string filePath = GetDefaultConfigPath();
241 | string jsonContent = await ReadJsonFromFileAsync(filePath);
242 | JsonNode jsonObject = JsonNode.Parse(jsonContent) ?? new JsonObject();
243 |
244 | foreach (var group in jsonObject.AsObject()) {
245 | if (group.Value["groupName"]?.GetValue() == groupName) {
246 | JsonObject paths = group.Value["path"]?.AsObject();
247 |
248 | if (paths != null) {
249 | var allTasks = new List();
250 |
251 | foreach (var pathEntry in paths) {
252 | string path = pathEntry.Key;
253 | string args = pathEntry.Value?["args"]?.GetValue() ?? string.Empty;
254 |
255 | allTasks.Add(Task.Run(() =>
256 | {
257 | try {
258 | ProcessStartInfo startInfo = new ProcessStartInfo(path) {
259 | Arguments = args,
260 | UseShellExecute = true
261 | };
262 | Process.Start(startInfo);
263 | }
264 | catch (Exception ex) {
265 | Debug.WriteLine($"Error launching process: {ex.Message}");
266 | }
267 | }));
268 | }
269 |
270 | await Task.WhenAll(allTasks);
271 | }
272 |
273 | break;
274 | }
275 | }
276 | }
277 | catch (Exception ex) {
278 | Debug.WriteLine($"Error launching all paths under group '{groupName}': {ex.Message}");
279 | }
280 | }
281 |
282 | private static void CopyDirectory(string sourceDir, string destinationDir, string originalGroupName, string newGroupName) {
283 | Directory.CreateDirectory(destinationDir);
284 |
285 | foreach (string file in Directory.GetFiles(sourceDir)) {
286 | string fileName = Path.GetFileName(file);
287 | string destFile = Path.Combine(destinationDir, fileName);
288 |
289 | if (fileName.Contains(originalGroupName)) {
290 | string newFileName = fileName.Replace(originalGroupName, newGroupName);
291 | destFile = Path.Combine(destinationDir, newFileName);
292 | }
293 |
294 | System.IO.File.Copy(file, destFile);
295 |
296 | FileAttributes attributes = System.IO.File.GetAttributes(file);
297 | System.IO.File.SetAttributes(destFile, attributes);
298 |
299 | if (Path.GetExtension(file).Equals(".lnk", StringComparison.OrdinalIgnoreCase)) {
300 | UpdateShortcutTarget(destFile, originalGroupName, newGroupName);
301 | }
302 | }
303 |
304 | foreach (string subDir in Directory.GetDirectories(sourceDir)) {
305 | string subDirName = Path.GetFileName(subDir);
306 |
307 | if (subDirName.Contains(originalGroupName)) {
308 | subDirName = subDirName.Replace(originalGroupName, newGroupName);
309 | }
310 |
311 | string newSubDir = Path.Combine(destinationDir, subDirName);
312 | CopyDirectory(subDir, newSubDir, originalGroupName, newGroupName);
313 |
314 | FileAttributes attributes = new DirectoryInfo(subDir).Attributes;
315 | new DirectoryInfo(newSubDir).Attributes = attributes;
316 | }
317 | }
318 |
319 | public static void UpdateShortcutIcon(string shortcutPath, string originalGroupName, string newGroupName) {
320 | try {
321 | WshShell wshShell = new WshShell();
322 | IWshShortcut shortcut = (IWshShortcut)wshShell.CreateShortcut(shortcutPath);
323 |
324 | // Get the old icon location
325 | string oldIconLocation = shortcut.IconLocation;
326 |
327 | // Update the icon location
328 | string newIconLocation = oldIconLocation.Replace(originalGroupName, newGroupName);
329 | shortcut.IconLocation = newIconLocation;
330 |
331 | shortcut.Save();
332 | }
333 | catch (Exception ex) {
334 | throw new Exception($"Error updating shortcut icon: {ex.Message}", ex);
335 | }
336 | }
337 |
338 |
339 |
340 | private static void UpdateShortcutTarget(string shortcutPath, string originalGroupName, string newGroupName) {
341 | try {
342 | WshShell wshShell = new WshShell();
343 | IWshShortcut shortcut = (IWshShortcut)wshShell.CreateShortcut(shortcutPath);
344 |
345 | string targetPath = shortcut.TargetPath.Replace(originalGroupName, newGroupName);
346 | shortcut.TargetPath = targetPath;
347 | shortcut.Arguments = $"\"{newGroupName}\"";
348 | shortcut.Description = $"{newGroupName} - AppGroup Shortcut";
349 |
350 | // Update the icon location if necessary
351 | string iconPath = shortcut.IconLocation.Replace(originalGroupName, newGroupName);
352 | shortcut.IconLocation = iconPath;
353 |
354 | shortcut.Save();
355 | }
356 | catch (Exception ex) {
357 | throw new Exception($"Error updating shortcut target: {ex.Message}", ex);
358 | }
359 | }
360 | public static bool GroupExistsInJson(string groupName) {
361 | string jsonPath = GetDefaultConfigPath();
362 | if (File.Exists(jsonPath)) {
363 | string jsonContent = File.ReadAllText(jsonPath);
364 | using (JsonDocument document = JsonDocument.Parse(jsonContent)) {
365 | JsonElement root = document.RootElement;
366 |
367 | foreach (JsonProperty property in root.EnumerateObject()) {
368 | if (property.Value.TryGetProperty("groupName", out JsonElement groupNameElement) &&
369 | groupNameElement.GetString() == groupName) {
370 | return true;
371 | }
372 | }
373 | }
374 | }
375 | return false;
376 | }
377 | public static bool GroupIdExists(int groupId) {
378 | string jsonPath = GetDefaultConfigPath();
379 |
380 | if (File.Exists(jsonPath)) {
381 | string jsonContent = File.ReadAllText(jsonPath);
382 | using (JsonDocument document = JsonDocument.Parse(jsonContent)) {
383 | JsonElement root = document.RootElement;
384 |
385 | foreach (JsonProperty property in root.EnumerateObject()) {
386 | if (property.Value.TryGetProperty("groupId", out JsonElement groupIdElement) &&
387 | groupIdElement.GetInt32() == groupId) {
388 | return true;
389 | }
390 | }
391 | }
392 | }
393 | return false;
394 | }
395 |
396 |
397 | public static void OpenGroupFolder(int groupId) {
398 | try {
399 | string filePath = GetDefaultConfigPath();
400 | string jsonContent = ReadJsonFromFile(filePath);
401 | JsonNode jsonObject = JsonNode.Parse(jsonContent) ?? new JsonObject();
402 |
403 | if (jsonObject.AsObject().ContainsKey(groupId.ToString())) {
404 | string groupName = jsonObject[groupId.ToString()]?["groupName"]?.GetValue();
405 |
406 | if (!string.IsNullOrEmpty(groupName)) {
407 | string exeDirectory = Path.GetDirectoryName(Environment.ProcessPath);
408 | string groupsFolder = Path.Combine(exeDirectory, "Groups");
409 | string groupFolderPath = Path.Combine(groupsFolder, groupName);
410 |
411 | if (Directory.Exists(groupFolderPath)) {
412 | Process.Start(new ProcessStartInfo {
413 | FileName = "explorer.exe",
414 | Arguments = groupFolderPath,
415 | UseShellExecute = true
416 | });
417 | }
418 | else {
419 | Debug.WriteLine($"The folder for group '{groupName}' does not exist.");
420 | }
421 | }
422 | else {
423 | Debug.WriteLine("Group name not found in the configuration.");
424 | }
425 | }
426 | else {
427 | Debug.WriteLine($"Group ID {groupId} not found in the configuration.");
428 | }
429 | }
430 | catch (Exception ex) {
431 | Debug.WriteLine($"Error opening group folder: {ex.Message}");
432 | }
433 | }
434 | }
435 | }
436 |
--------------------------------------------------------------------------------
/AppGroup/MainWindow.xaml:
--------------------------------------------------------------------------------
1 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 | App Group
32 |
33 |
34 |
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 | 
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 |

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 |
124 |
125 |
126 |
127 |
128 | ## License
129 | This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
130 |
131 |
--------------------------------------------------------------------------------