Microsoft Edge (Chromium) sets a bad and good example: the case of Taskbar pinning

A screenshot of Edge (Chromium)
A screenshot of Edge (Chromium)

Microsoft Edge (Chromium) can pin websites to Taskbar in an undocumented way. Though Edge only pins websites upon user request, this still feels very itchy to me. To be fair, even Internet Explorer in Windows 7 did not cheat (the user has to drag and drop to Taskbar) using Microsoft internal knowledge. I feel it would be more fair to just show everyone how to do programmatic pinning. Microsoft will now have two choices: either accept the truth but still promote good UX design, or deprecate the old API and stop cheating in their web browser.

What does this mean for you, developers? You can use the method written in this blog entry to pin Shortcuts to Taskbar (no guarantee that it will work in future versions of Windows), but only with explicit user consent. This includes not checking ‘Pin me to Taskbar’ by default. Or you’re a despicable douchebag.

TL;DR. See how to pin/unpin Shortcuts programmatically (that works as of 1909).

Pinning apps to Taskbar is a capability first introduced in Windows 7. Ever since its invention, Microsoft (according to Raymond Chen) intended to not give (unlimited) programmatic manipulation. Obviously, some developers decided that their app is the most awesome in the world, and who don’t want our app pinned to Taskbar upon installation?!

Despite the trivial method of ShellExecuting a Shortcut with verb taskbarpin (with SEE_MASK_INVOKEIDLIST flag set) stopped working in Windows 10. Microsoft added a check to see whether the calling process is explorer.exe before enumerating the verb, which also removes ‘Pin to taskbar’ from ‘Open File’ dialog of apps (e.g., Notepad). However, ingenious developers know how to fix this. Again, Microsoft did a dirty fix in Windows 10 version 1809, by checking (in shell32.dll!CTaskbandPin::v_AllowVerb) whether the calling process is explorer.exe before proceeding with IID_IContextMenu::InvokeCommand (it only declined to enumerate the verb up to 1803).

There are only a few documented ways of manipulating Taskbar pinning:

  • Use Group Policy to set the default pinned apps (user can still edit them). The settings are reapplied each time the layout file updates.
  • Use Import-StartLayout or provision packages to enforce the pinned apps. The settings are reapplied each time explorer.exe starts.
  • Use Group Policy to disable pinning.
  • Use Windows.UI.Shell.TaskbarManager class. This applies only if your app has a package identity (a future update to Windows will allow you to assign identity to non-MSIX/APPX apps, which is quite convenient; one could build one’s own RuntimeBroker.exe).

Now, Microsoft Edge (Chromium) could pin websites to Taskbar, as a non-packaged app. I had a few speculations about how it achieved that. The first idea is that Edge use PEB manipulation to look like explorer.exe, but that’s too crazy. Another idea is that Edge has a dedicated binary called explorer.exe that helps it pin the Shortcut. Searching in the installation directory yielded no such thing. A third idea is that Edge injects into Explorer to pin the Shortcut. However, Edge can pin to Taskbar even if explorer.exe is not running (and even if the current user is ACL-denied to run explorer.exe). The most probable option is that Edge itself pins the Shortcut using some undocumented method.

Let’s figure this out.

Observing msedge.exe

I was running Microsoft Edge 81.0.396.0. It is well known that every time Taskbar pinning changes, the registry value HKCU\Software\Microsoft\Windows\CurrentVersion\Explorer\Taskband\Favorites will have to change. Therefore, the first step is to launch Process Monitor and watch to registry operations to this path. Open Edge and pin a website, you will see it’s the msedge.exe (with non-empty main window caption) that is manipulating the registry. Looking at the stack, it calls into shell32.dll at PinShortcutToTaskbar function inside microsoft_shell_integration.dll.

Now go to Control Panel > Uninstall a program and uninstall the pinned website (I have no idea why Edge would make it an uninstallable; it’s non-sense). Open Visual Studio and attach debugger to msedge.exe. At this point, Visual Studio will start loading symbols from Microsoft Symbol Server, so just wait patiently.

Once the debugger runs normally, set a breakpoint at function PinShortcutToTaskbar, and pin a website again. The code will break into the debugger and you can step in/over/out. In fact, we should copy the disassembly. (I’m no expert in reading disassembly code, but this doc helped.) A simple inspection reveals that the function beginning at 0x7ffed157101b ends at 0x7ffed1571223. Copy the code and use Immediate Window of Visual Studio to figure out where each call instruction goes.

There are quite a few direct calls. Those are all inside microsoft_shell_integration.dll, thus not interesting. Then there are three indirect calls at fixed addresses, one CoCreateInstance (in combase.dll), another ILCreateFromPathW (in shell32.dll), and the last CoTaskMemFree (again in combase.dll). They can be deciphered using ? (void *)0x<address> or ? *(void **)0x<address> in Immediate Window. None of them will pin things to Taskbar.

Lastly, there are two twice indirect calls. Obviously they are COM interface methods, and one of them must be Release. So we’re interested in the first twice indirect call. But let’s set a breakpoint at CoCreateInstance and F5 there to see what COM instance it is working on. By stepping into it (then stepping out), we see

Parameter Value
rclsid CLSID_TaskbanPin
pUnkOuter NULL
dwClsContext CLSCTX_ALL
riid {0dd79ae2-d156-45d4-9eeb-3b549769e940}
ppv rsp + 0x30
Return S_OK

Next, we set a breakpoint at ILCreateFromPathW and F5 there. Using ? *(wchar_t *)r14 we see the function is acquiring an item ID list to <Local AppData>\Microsoft\Edge Dev\User Data\Default\Web Applications\<some id>\<pinned title>.lnk.

Then, we set a breakpoint at the next call and F5 there. It is calling *(QWORD *)(rax + 0x80). Since rcx is the zeroth parameter (this) and rax is *(QWORD *)rcx, it must be the virtual table pointer. Execute ? (void *)rax in Immediate Window, we see it’s shell32.dll!const ATL::CComObject<class CTaskbandPin>::`vftable'{for `IPinnedList3'}, the virtual table of the IID_IPinnedList3 interface of the CLSID_TaskbanPin object. Of course, this IDD_IPinnedList3 is the riid passed to CoCreateInstance. Let’s execute ? *(void **)rax + 8 * k for k from 0 to as large as needed, to enumerate the methods on this interface. We get a long list:

shell32.dll![thunk]:ATL::CComObject<class CTaskbandPin>::QueryInterface`adjustor{24}' (struct _GUID const &,void * *)
shell32.dll![thunk]:ATL::CComObject<class CClientExtractIcon>::AddRef`adjustor{24}' (void)
shell32.dll![thunk]:ATL::CComObject<class CStartMenuPin>::Release`adjustor{24}' (void)
shell32.dll![thunk]:CPinnedList::EnumObjects`adjustor{8}' (struct IEnumFullIDList * *)
shell32.dll![thunk]:CPinnedList::GetPinnableInfo`adjustor{8}' (struct IDataObject *,enum PINNABLEFLAG,struct IShellItem * *,struct IShellItem * *,unsigned short * *,int *)
shell32.dll![thunk]:CPinnedList::IsPinnable`adjustor{8}' (struct IDataObject *,enum PINNABLEFLAG)
shell32.dll![thunk]:CPinnedList::Resolve`adjustor{8}' (struct HWND__ *,unsigned long,struct _ITEMIDLIST_ABSOLUTE const *,struct _ITEMIDLIST_ABSOLUTE * *)
shell32.dll![thunk]:CPinnedList::LegacyModify`adjustor{8}' (struct _ITEMIDLIST_ABSOLUTE const *,struct _ITEMIDLIST_ABSOLUTE const *)
shell32.dll![thunk]:CPinnedList::GetChangeCount`adjustor{8}' (unsigned long *)
shell32.dll![thunk]:CPinnedList::IsPinned`adjustor{8}' (struct _ITEMIDLIST_ABSOLUTE const *)
shell32.dll![thunk]:CPinnedList::GetPinnedItem`adjustor{8}' (struct _ITEMIDLIST_ABSOLUTE const *,struct _ITEMIDLIST_ABSOLUTE * *)
shell32.dll![thunk]:CPinnedList::GetAppIDForPinnedItem`adjustor{8}' (struct _ITEMIDLIST_ABSOLUTE const *,unsigned short * *)
shell32.dll![thunk]:CPinnedList::ItemChangeNotify`adjustor{8}' (struct _ITEMIDLIST_ABSOLUTE const *,struct _ITEMIDLIST_ABSOLUTE const *)
shell32.dll![thunk]:CPinnedList::UpdateForRemovedItemsAsNecessary`adjustor{8}' (void)
shell32.dll![thunk]:CPinnedList::PinShellLink`adjustor{8}' (unsigned short const *,struct IShellLinkW *)
shell32.dll![thunk]:CPinnedList::GetPinnedItemForAppID`adjustor{8}' (unsigned short const *,struct _ITEMIDLIST_ABSOLUTE * *)
shell32.dll!CPinnedList::Modify(struct _ITEMIDLIST_ABSOLUTE const *,struct _ITEMIDLIST_ABSOLUTE const *,enum PINNEDLISTMODIFYCALLER)

Now, the code is calling the method, so it’s IPinnedList3::Modify. It passes rdx being zero, r8 being the value returned from ILCreateFromPathW and r9 being 0x22. We can infer that the second parameter is for specifying the Shortcut to pin. It’s not immediately clear what PINNEDLISTMODIFYCALLER is used for.

Step over this method call, you will see the Shortcut is pinned. The function continues to call CoTaskMemFree on the PIDL returned by ILCreateFromPathW. Wait, but the documentation says you should use ILFree? In Disassembly window, jump to shell32.dll!ILFree, we see it simply jumps to CoTaskMemFree. Probably this is one of the remnants of the shell’s mini COM. Though I would advise calling ILFree still.

Lastly, the function releases the interface and does some other works.

Exercise. Why does there seem to be so many seemingly extraneous, repetitive methods?

Answer. There were IID_IPinnedList and IID_IPinnedList2. I searched the Internet and found this page, which shows the first two versions of this interface.

Other observations

Warning Debugging Explorer could make your Desktop environment hang and easily causes deadlocks; it is suggested that you save your work before you begin, and use Ctrl+Alt+Del to sign out.

By debugging other processes, it’s easy to observe two things:

  • Explorer, when using the IID_IContextMenu of CLSID_TaskbandPin, will pass 4 as the third parameter.
  • The IID_IContextMenu interface will use CTaskbandPin::v_AllowVerb to decide whether or not to show the taskbarpin verb.
  • Microsoft Edge (EdgeHTML) will pass 28, while Edge (Chromium) passes 34.
  • Cortana/Search/Start Menu (or in general, RuntimeBroker.exe) passes 32.
  • The third parameter seems to be a telemetric one.
  • The first parameter is used for unpinning a Shortcut, and the second is for pinning.

Program to pin and unpin Shortcuts

It’s not too hard to write some C++ code to perform a sequence to pin/unpin a Shortcut. Basically, we follow the steps used by Edge (Chromium):

  1. Initialise COM.
  2. Use ILCreateFromPathW to get an absolute PIDL.
  3. Use CoCreateInstance to activate CLSID_TaskbandPin with interface IID_IPinnedList3.
  4. Use Modify method with the PIDL to pin/unpin the target.
  5. Release the interface, the PIDL and uninitialse COM.

You can get an example here under MIT license. Once compiled, use pin "C:\path\to\file.lnk" to pin a Shortcut, and pin "C:\path\to\file.lnk" u to unpin it.

Again, DO NOT ABUSE THIS PIECE OF CODE. Either you use it for your own deployment, or you get EXPLICIT user consent before pinning yourself. And ‘no doesn’t mean yes’. You also should not bug the user by repeatedly prompting for pinning without user’s explicit intention.

Edge (Chromium) sets a bad example in the sense that it cheats by using API internal to WinRT and Shell. However, it only uses this knowledge in two scenarios, which is the good example:

  • Replacing its predecessor’s pinned Shortcuts.
  • Pinning a website only when the user actively asks it to do so.

Please enable JavaScript to view the comments powered by Disqus.