Windows 固定快捷方式到任务栏

发布于:2024-05-23 ⋅ 阅读:(133) ⋅ 点赞:(0)

目录

前言

一、通过 ShellExecute 配合动词 taskbarpin 固定到任务栏

二、通过 COM 操作模拟点击右键菜单选项卡

三、效仿 Edge 浏览器使用未公开的 COM 接口

四、获取并修改已固定项的列表顺序

五、目前已知的开源解决方案

六、通过 WinRT API 受限控制任务栏和开始菜单

参考文献


本文出处链接:[https://blog.csdn.net/qq_59075481/article/details/139028308]

前言

将 应用程序固定到任务栏 是 Windows 7 中首次引入的一项功能。自其发明以来,微软(根据 Raymond Chen 的说法)就打算不提供(无限的)编程操作。显然,一些开发人员认为他们的应用程序是世界上最棒的,谁不希望我们的应用程序 在安装时固定到任务栏?!

目前操作任务栏固定的方法有很多种,这里整理常用的几种方法。

一、通过 ShellExecute 配合动词 taskbarpin 固定到任务栏

尽管使用 ShellExecute 并带有动词 taskbarpin(和 SEE_MASK_INVOKEIDLIST 标志集)来固定快捷方式的方法很简单,但是在 Windows 10 已经停止工作。

ShellExecuteW(NULL, L"taskbarpin", shortcut, NULL, NULL, 0); // 固定到任务栏
ShellExecuteW(NULL, L"taskbarunpin", shortcut, NULL, NULL, 0); // 从任务栏解除

在枚举动词之前,微软添加了一项检查来查看调用进程是否是 explorer.exe,并且从应用程序(例如记事本)的“打开文件”对话框中删除“固定到任务栏” 选项。然而,开发人员知道如何解决这个问题

随后,微软在 Windows 10 版本 1809 中进一步完善了检查机制,通过在shell32.dll!CTaskbandPin::v_AllowVerb 中进一步检查进程是否是 explorer.exe 以避免利用 IID_IContextMenu::InvokeCommand 未检查来源的绕过技巧。

只有几种记录在案的操作任务栏固定的方法:

  • 使用组策略设置默认固定的应用程序(用户仍然可以编辑它们)。每次布局文件更新时,都会重新应用这些设置。
  • 使用导入StartLayout或设置包来强制执行固定的应用程序。每次 explorer.exe 启动时都会重新应用这些设置。
  • 使用组策略禁用固定。
  • 使用 Windows.UI.Shell.TaskbarManager 类。这仅适用于应用程序具有程序包标识的情况(将来对 Windows 的更新将允许开发者将标识分配给非 MSIX/APPX 应用程序,这非常方便;你可以构建自己的 RuntimeBroker.exe)。

二、通过 COM 操作模拟点击右键菜单选项卡

这种方法有很多缺陷,比如必须伪装成 explorer.exe (explorer.exe 必须处于正在运行状态)并且模拟在 Shell 的右键菜单中的操作(必须是右键菜单中拥有的选择项),这类操作是容易被中断的,并且实现的兼容性不高(字符串一改或者正在操作右键菜单就失效了)。

注意:右键菜单中的选项会随着系统版本不同而有所变化,最佳的方法是通过 LoadStringW 从 shell32.dll 加载包含特征的字符串资源。下图是 Win11 23H2 目前的字符串 ID(包括固定到任务栏、从任务栏取消固定、固定到开始菜单、从"开始"屏幕取消固定):

我在另外一篇博客简单介绍了 LoadString 获取字符串资源的方法:

获取 Dll 模块的加载字符串资源-CSDN博客

COM 调用 Shell 右键菜单项的代码: 

#include <shldisp.h>
NTSTATUS PinToTaskbar(PCWSTR pFolder, PCWSTR pName)
{
    if (!pFolder || !pName)
        return ERROR_INVALID_PARAMETER;
    // 初始化COM组件
    CoInitialize(NULL);
    // 获取shell的CLSID
    CLSID clsid = { 0 };
    HRESULT hr = CLSIDFromProgID(L"Shell.Application", &clsid);
    if (FAILED(hr)) return HRESULT_CODE(hr);
    // 获取右键菜单列表
    BSTR bs = NULL;
    VARIANT var = { VT_BSTR };
    IShellDispatch* pisd = NULL;
    Folder* pf = NULL;
    FolderItem* pfi = NULL;
    FolderItemVerbs* pfivs = NULL;
    FolderItemVerb* pfiv = NULL;
    do 
    {
        // 创建shell实例
        hr = CoCreateInstance(clsid, NULL,
            CLSCTX_INPROC_SERVER, IID_IDispatch, (void**)&pisd);
        if (FAILED(hr)) break;
        // 处理文件路径
        var.bstrVal = SysAllocString(pFolder);
        if (!var.bstrVal) break;
        hr = pisd->NameSpace(var, &pf);
        if (FAILED(hr)) break;
        // 处理文件名
        bs = SysAllocString(pName);
        if (!bs) break;
        hr = pf->ParseName(bs, &pfi);
        if (FAILED(hr)) break;
        // 获取右键菜单列表
        hr = pfi->Verbs(&pfivs);
        if (FAILED(hr)) break;
        long n = 0;
        hr = pfivs->get_Count(&n);
        if (FAILED(hr)) break;
        // 循环遍历右键菜单列表
        BSTR name = NULL;
        BOOL bRet = FALSE;
        VARIANT i = { VT_I4 };
        for (i.lVal = 0; i.lVal < n; i.lVal++)
        {
            hr = pfivs->Item(i, &pfiv);
            if (FAILED(hr)) continue;
            // 对比右键菜单项的名称
            hr = pfiv->get_Name(&name);
            if (SUCCEEDED(hr))
            {
                if (!wcscmp(name, L"固定到任务栏(&K)"))
                {
                    // 执行目标项
                    hr = pfiv->DoIt();
                    bRet = TRUE;
                }
                SysFreeString(name);
            }
            pfiv->Release();
            if (bRet) break;
        }
    } while (0);
    // 释放所用的数据
    if (bs) SysFreeString(bs);
    if (var.bstrVal) SysFreeString(var.bstrVal);
    if (pfivs) pfivs->Release();
    if (pfi) pfi->Release();
    if (pf) pf->Release();
    if (pisd) pisd->Release();
    return HRESULT_CODE(hr);
}

伪装成 explorer 进程(可能在高版本已经失效):

#include <winternl.h>
#pragma comment(lib, "ntdll.lib")
NTSTATUS FakeToExplorer()
{
    // 获取explorer的路径
    WCHAR szwPath[MAX_PATH] = { 0 };
    GetWindowsDirectoryW(szwPath, MAX_PATH);
    wcscat_s(szwPath, L"\\Explorer.exe");
    // 查询当前程序的PEB信息
    PROCESS_BASIC_INFORMATION pbi = { 0 };
    NTSTATUS ret = NtQueryInformationProcess(GetCurrentProcess(),
        ProcessBasicInformation, &pbi, sizeof(pbi), NULL);
    if (!NT_SUCCESS(ret)) return ret;
    // 检查当前程序路径缓冲区是否足够
    USHORT n = (USHORT)wcslen(szwPath);
    if (pbi.PebBaseAddress->ProcessParameters->ImagePathName.MaximumLength / 2 <= n)
        return ERROR_INSUFFICIENT_BUFFER;
    // 伪装成explorer路径,必须要有NULL结尾符
    memcpy(pbi.PebBaseAddress->ProcessParameters->ImagePathName.Buffer, szwPath, n * 2);
    pbi.PebBaseAddress->ProcessParameters->ImagePathName.Buffer[n] = 0; // 结尾符
    pbi.PebBaseAddress->ProcessParameters->ImagePathName.Length = n * 2;
    return ERROR_SUCCESS;
}

目前有效的方法——注入远程线程到 explorer 进程,主要过程如下:

  1. 通过 Findwindow 获取 Progman 窗口句柄,通过 GetWindowThreadProcessId 获取进程 id,再通过 OpenProcess 打开进程句柄
  2. 计算上面实现 COM 操作函数的指令范围,通过偏移量修改 LoadString 的输入参数
  3. 在打开的 explorer 进程句柄中通过 VirtualAllocEx 申请足够的内存空间。
  4. WriteProcessMemory 拷贝 COM 操作函数的指令到申请的内存空间。
  5. CreateRemoteThread 创建远程线程,并将 lpThreadAttributes 参数指向函数的首地址。
  6. 通过 explorer.exe 代为完成 “固定/取消固定” 的操作。

下图是逆向一款桌面美化工具 IconPin.exe 组件,它所采用的方法就是上面的远程线程注入 + COM 模拟操作法。

它的伪代码如下:

__int64 ShellIconsPinUnPinHandler()
{
  HMODULE ModuleHandleW; // r15
  const WCHAR *CommandLineW; // rax
  LPWSTR *lpCommandArgsList; // rbx
  unsigned int lpRemoteFuncAddress; // r12d
  signed int dwParamDataOffset; // ebp
  int dwArgsNum; // eax
  int index; // esi
  LPCWSTR *lpWstr; // rdi
  WCHAR *pArg_1_str; // rcx
  UINT uSwitchMode; // edi
  LPWSTR pArg_1_lowerStr; // rax
  int dwStringLength_1; // eax
  __int64 g_dwStringLength; // rsi
  __int64 j; // rcx
  int dwStringLength; // eax
  __int64 i; // rcx
  const WCHAR *v17; // rcx
  HANDLE FirstFileW; // rax
  int v19; // edx
  int v20; // r8d
  __int64 index_v2; // rax
  WCHAR wsFileNameStr; // cx
  WCHAR *pNextWord; // r8
  int cnt_V0; // r10d
  __int64 index_v0; // rcx
  WCHAR pNext_v0; // ax
  HWND hShellProgmanWnd; // rax
  HWND hWnd; // r14
  const WCHAR *eLogString; // rcx
  HANDLE hTargetProcess; // rdi
  __int64 lpRemoteFuncStruct_cp1; // rbx
  SIZE_T dwCopySize; // rbx
  void *lpTargetAddress; // rbp
  _BYTE *lpSourceAddress; // rsi
  int index_v3; // edx
  _BYTE *pNext; // rcx
  signed __int64 index_v4; // r15
  DWORD LastError; // eax
  HANDLE RemoteThread; // rbx
  HANDLE CurrentProcess; // rax
  struct _WIN32_FIND_DATAW FindFileData; // [rsp+40h] [rbp-288h] BYREF
  int pNumArgs; // [rsp+2D0h] [rbp+8h] BYREF
  DWORD dwProcessId; // [rsp+2D8h] [rbp+10h] BYREF
  SIZE_T NumberOfBytesWritten; // [rsp+2E0h] [rbp+18h] BYREF

  ModuleHandleW = GetModuleHandleW(0i64);
  if ( !ModuleHandleW )
  {
    OutputDebugStringW(L"Fatal Error: GetModuleHandle failed\n");
    return 0i64;
  }
  CommandLineW = GetCommandLineW();
  pNumArgs = 0;
  lpCommandArgsList = CommandLineToArgvW(CommandLineW, &pNumArgs);
  if ( !lpCommandArgsList )
  {
    OutputDebugStringW(L"CommandLineToArgvW Failed");
    return 0i64;
  }
  OutputDebugStringW(L"1.0");
  if ( (unsigned int)(pNumArgs - 2) > 1 )
  {
    OutputDebugStringW(L"paramcount = 1 or = 4");
    return 0i64;
  }
  OutputDebugStringA("DoVerbOnFile Address 1:");
  DebugOutputHandler((unsigned int)RemoteShellDoVerbOnFileFunc);
  OutputDebugStringA("hModule Address:");
  DebugOutputHandler((unsigned int)ModuleHandleW);
  lpRemoteFuncAddress = (unsigned int)RemoteShellDoVerbOnFileFunc - (_DWORD)ModuleHandleW;
  OutputDebugStringA("DoVerbOnFile Address 2:");
  DebugOutputHandler(lpRemoteFuncAddress);
  OutputDebugStringA("ParamData address:");
  DebugOutputHandler((unsigned int)FileName);
  dwParamDataOffset = (unsigned int)FileName - (_DWORD)ModuleHandleW;
  OutputDebugStringA("ParamDataAddressOffser");
  DebugOutputHandler(dwParamDataOffset);
  OutputDebugStringA("sizeof(ParamData)");
  DebugOutputHandler(524u);
  OutputDebugStringA("paramcount");
  DebugOutputHandler(pNumArgs);
  // 解析命令行参数列表
  // 解析模式:根据关键词分类。
  dwArgsNum = pNumArgs;
  index = 0;
  if ( pNumArgs > 0 )
  {
    lpWstr = (LPCWSTR *)lpCommandArgsList;
    do
    {
      OutputDebugStringW(*lpWstr);
      dwArgsNum = pNumArgs;
      ++lpWstr;
      ++index;
    }
    while ( index < pNumArgs );
  }
  pArg_1_str = lpCommandArgsList[1];
  uSwitchMode = 0x150A;
  if ( dwArgsNum == 2 )
  {
    dwStringLength = lstrlenW(pArg_1_str);
    g_dwStringLength = dwStringLength;
    if ( dwStringLength < 260 )
    {
      for ( i = 0i64; i < dwStringLength; ++i )
        FileName[i] = lpCommandArgsList[1][i];
      goto LABEL_29;
    }
    goto LABEL_26;
  }
  // 解析命令行参数要执行的操作
  uSwitchMode = 0;
  pArg_1_lowerStr = CharLowerW(pArg_1_str);     // 转换为小写字母
  lpCommandArgsList[1] = pArg_1_lowerStr;
  if ( (unsigned int)ConsoleCommandsHandler(pArg_1_lowerStr, L"pin", 3)
    && (unsigned int)ConsoleCommandsHandler(lpCommandArgsList[1], L"pin_taskbar", 11) )
  {
    if ( (unsigned int)ConsoleCommandsHandler(lpCommandArgsList[1], L"unpin", 5)
      && (unsigned int)ConsoleCommandsHandler(lpCommandArgsList[1], L"unpin_taskbar", 13) )
    {
      if ( (unsigned int)ConsoleCommandsHandler(lpCommandArgsList[1], L"pin_startmenu", 13) )
      {
        if ( !(unsigned int)ConsoleCommandsHandler(lpCommandArgsList[1], L"unpin_startmenu", 15) )
          uSwitchMode = 0xC8C2;
      }
      else
      {
        uSwitchMode = 0xC801;
      }
    }
    else
    {
      uSwitchMode = 0x150B;
    }
  }
  else
  {
    uSwitchMode = 0x150A;
  }
  dwStringLength_1 = lstrlenW(lpCommandArgsList[2]);
  g_dwStringLength = dwStringLength_1;
  if ( dwStringLength_1 >= 260 )
  {
LABEL_26:
    uSwitchMode = 0;
    OutputDebugStringW(L"PinPathlen over buffer");
    goto LABEL_30;
  }
  if ( uSwitchMode )
  {
    for ( j = 0i64; j < dwStringLength_1; ++j )
      FileName[j] = lpCommandArgsList[2][j];
LABEL_29:
    FileName[g_dwStringLength] = 0;
  }
LABEL_30:
  // 根据不同的命令行,传递给 COM 的右键菜单项是特定字符串,通过资源 ID 动态获取字符串内容。
  switch ( uSwitchMode )
  {
    case 0u:
      eLogString = L"kUnknow";
      goto LABEL_52;
    case 0x150Au:
      v17 = L"kPinToTaskbarID";
      break;
    case 0x150Bu:
      v17 = L"kUnpinFromTaskbarID";
      break;
    case 0xC801u:
      v17 = L"kPinToStartID";
      break;
    default:
      v17 = L"kUnpinFromStartID";
      break;
  }
  // 检查文件路径是否存在,文件格式是否正确
  OutputDebugStringW(v17);
  OutputDebugStringW(FileName);
  FirstFileW = FindFirstFileW(FileName, &FindFileData);
  if ( FirstFileW == (HANDLE)-1i64 )
  {
    OutputDebugStringA("File Not exist");
    return 0i64;
  }
  FindClose(FirstFileW);
  v19 = -1;
  uID = uSwitchMode;
  v20 = 0;
  index_v2 = 0i64;
  if ( (int)g_dwStringLength <= 0 )
    goto LABEL_72;
  do
  {
    wsFileNameStr = FileName[index_v2];
    if ( !wsFileNameStr )
      break;
    if ( wsFileNameStr == 0x5C )
      v19 = v20;
    ++v20;
    ++index_v2;
  }
  while ( index_v2 < (int)g_dwStringLength );
  if ( v19 == -1 )
  {
LABEL_72:
    eLogString = L"filenameandpath check failed";
    goto LABEL_52;
  }
  pNextWord = wsFilePath;
  cnt_V0 = 0;
  index_v0 = 0i64;
  do
  {
    pNext_v0 = FileName[index_v0];
    if ( index_v0 > v19 )
    {
      ++cnt_V0;
      *pNextWord++ = pNext_v0;
    }
    else
    {
      psz[index_v0] = pNext_v0;
    }
    ++index_v0;
  }
  while ( index_v0 < (int)g_dwStringLength );
  psz[v19 + 1] = 0;
  wsFilePath[cnt_V0] = 0;
  OutputDebugStringW(psz);
  OutputDebugStringW(wsFilePath);
  // 获取 Progman 窗口句柄,这是桌面窗口
  hShellProgmanWnd = FindWindowW(L"Progman", 0i64);
  hWnd = hShellProgmanWnd;
  if ( !hShellProgmanWnd )
  {
    eLogString = L"FindWindowW Progman Failed";
LABEL_52:
    OutputDebugStringW(eLogString);
    return 0i64;
  }
  dwProcessId = 0;
  // 获取窗口对应进程 ID
  GetWindowThreadProcessId(hShellProgmanWnd, &dwProcessId);
  if ( !dwProcessId )
  {
    eLogString = L"GetWindowThreadProcessId Failed";
    goto LABEL_52;
  }
  // 获取 explorer 进程的访问句柄
  hTargetProcess = OpenProcess(0x2Au, 0, dwProcessId);
  if ( !hTargetProcess )
  {
    eLogString = L"OpenProcess Failed";
    goto LABEL_52;
  }
  lpRemoteFuncStruct_cp1 = (int)lpRemoteFuncAddress;
  if ( dwParamDataOffset > (unsigned __int64)(int)lpRemoteFuncAddress )
    lpRemoteFuncStruct_cp1 = dwParamDataOffset;
  OutputDebugStringW(L"BufferSize");
  DebugOutputHandler(lpRemoteFuncStruct_cp1);
  dwCopySize = lpRemoteFuncStruct_cp1 + 4096;
  DebugOutputHandler(dwCopySize);
  // 申请内存空间
  lpTargetAddress = VirtualAllocEx(hTargetProcess, 0i64, dwCopySize, 0x3000u, 0x40u);
  if ( !lpTargetAddress )
  {
    CloseHandle(hTargetProcess);
    return 0i64;
  }
  lpSourceAddress = VirtualAlloc(0i64, dwCopySize, 0x3000u, 0x40u);
  OutputDebugStringW(L"lExplorMem");
  DebugOutputHandler((unsigned int)lpTargetAddress);
  if ( !lpSourceAddress )
  {
    VirtualFreeEx(hTargetProcess, lpTargetAddress, 0i64, 0x8000u);
    CloseHandle(hTargetProcess);
    CloseHandle(hWnd);
    eLogString = L"VirtualAlloc Failed";
    goto LABEL_52;
  }
  index_v3 = 0;
  if ( dwCopySize )
  {
    pNext = lpSourceAddress;
    index_v4 = (char *)ModuleHandleW - lpSourceAddress;
    do
    {
      ++index_v3;
      *pNext = pNext[index_v4];
      ++pNext;
    }
    while ( index_v3 < dwCopySize );
  }
  // 拷贝指令字节到申请的缓冲区中
  NumberOfBytesWritten = 0i64;
  if ( !WriteProcessMemory(hTargetProcess, lpTargetAddress, lpSourceAddress, dwCopySize, &NumberOfBytesWritten) )
  {
    VirtualFree(lpSourceAddress, 0i64, 0x8000u);
    VirtualFreeEx(hTargetProcess, lpTargetAddress, 0i64, 0x8000u);
    CloseHandle(hTargetProcess);
    LastError = GetLastError();
    DebugOutputHandler(LastError);
    eLogString = L"WriteProcessMemory 1 Failed";
    goto LABEL_52;
  }
  OutputDebugStringW(L"nsize");
  DebugOutputHandler(NumberOfBytesWritten);
  OutputDebugStringW(L"offset");
  DebugOutputHandler(lpRemoteFuncAddress);
  DebugOutputHandler(lpRemoteFuncAddress + (_DWORD)lpTargetAddress);
  // 创建远程线程执行函数
  RemoteThread = CreateRemoteThread(
                   hTargetProcess,
                   0i64,
                   0i64,
                   (LPTHREAD_START_ROUTINE)(int)(lpRemoteFuncAddress + (_DWORD)lpTargetAddress),
                   0i64,
                   0,
                   0i64);
  OutputDebugStringW(L"CreateRemoteThread Leave");
  // 判断是否成功执行远程线程
  if ( RemoteThread )
  {
    WaitForSingleObject(RemoteThread, 0x3A98u);
    TerminateThread(RemoteThread, 0);
    CloseHandle(RemoteThread);
  }
  // 释放内存关闭当前进程
  VirtualFree(lpSourceAddress, 0i64, 0x8000u);
  VirtualFreeEx(hTargetProcess, lpTargetAddress, 0i64, 0x8000u);
  CloseHandle(hTargetProcess);
  OutputDebugStringW(L"TerminateProcess");
  CurrentProcess = GetCurrentProcess();
  TerminateProcess(CurrentProcess, 1u);
  return 1i64;
}

三、效仿 Edge 浏览器使用未公开的 COM 接口

警告:一切使用未文档化的内部结构和接口完成的扩展功能,可能在未来导致您的程序无法工作。微软可能修改任何细节包括但不限于 COM 接口的迭代更新 / 废弃旧的机制 / 限制作用域。在使用此 COM 代码前,请仔细考虑潜在的缺陷对你所开发软件的影响。本文作者和原作者均不承担责任,此代码仅供学习所用。

Microsoft Edge (Chromium) 可以将网站作为非打包应用程序固定到任务栏,并且可以在 explorer.exe 未启动时就完成处理(现在不可以了,必须在 explorer 正常运行时才能弹出通知),在这过程中使用了未公开的 COM 细节(IPinnedList3 接口),相关接口目前已经被研究者发现并利用。

参数
rclsid CLSID_TaskbanPin
pUnkOuter NULL
dwClsContext CLSCTX_ALL
riid {0dd79ae2-d156-45d4-9eeb-3b549769e940}
ppv rsp + 0x30
返回值 S_OK

链接:Microsoft Edge (Chromium) 树立了一个坏的和好的例子:任务栏固定的情况

作者提供的示例代码里面有一个 PIDLFromPath 对象重复析构的错误:

当调用 pinnedList->vtbl->Modify 时,它会接受 PIDLFromPath 对象作为参数,并在函数执行完毕后销毁这个对象。这导致了在 main 函数返回之前就调用了 PIDLFromPath 的析构函数,从而导致了重复释放的问题。

下面予以修正。修正代码中向 Modify 方法传递参数时使用 PIDLFromPath 对象的副本,避免了主函数返回前对同一个对象的重复析构。

使用方法:命令行输入要固定/取消固定的快捷方式( lnk 文件)的绝对路径,后面附加参数 “u” 表示取消固定,否则默认为固定操作。

/*
Copyright (c) 2020 by Gee Law

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
**/

// 包含所需的头文件
#pragma comment(lib, "ole32")
#pragma comment(lib, "shell32")

#define STRICT_TYPED_ITEMIDS
#define WIN32_LEAN_AND_MEAN

#include <iostream>
#include<objbase.h>
#include<shlobj.h>
#include<cstdio>

// 定义用于自动初始化和清理 COM 的结构体
struct CoInitializeGuard
{
    HRESULT const hr;

    // 构造函数用于初始化 COM
    CoInitializeGuard() noexcept : hr(CoInitialize(NULL)) { }

    // 析构函数用于清理 COM
    ~CoInitializeGuard() noexcept
    {
        if (SUCCEEDED(hr))
        {
            CoUninitialize();
        }
    }

    // 将 HRESULT 类型转换为 bool 类型,方便使用
    operator HRESULT() const { return hr; }
};

// 定义从文件路径获取 PIDL 的结构体
struct PIDLFromPath
{
    PIDLIST_ABSOLUTE pidl;

    // 构造函数根据文件路径创建 PIDL
    PIDLFromPath(PCWSTR path) noexcept : pidl(ILCreateFromPathW(path)) { }

    // 析构函数用于释放 PIDL 内存
    ~PIDLFromPath()
    {
        if (pidl)
        {
            ILFree(pidl);
        }
    }

    // 复制构造函数用于创建当前对象的副本
    PIDLFromPath(const PIDLFromPath& other) noexcept
    {
        // 复制 pidl 对象
        pidl = reinterpret_cast<PIDLIST_ABSOLUTE>(ILClone(other.pidl));
    }

    // 移动构造函数用于转移其他对象的 PIDL
    PIDLFromPath(PIDLFromPath&& other) noexcept
    {
        // 移动 pidl 对象
        pidl = other.pidl;
        other.pidl = nullptr; // 避免被其他对象释放
    }

    // 复制赋值运算符用于从其他对象复制 PIDL
    PIDLFromPath&operator=(const PIDLFromPath& other) noexcept
    {
        if (this != &other)
        {
            // 释放当前 pidl 对象
            if (pidl)
            {
                ILFree(pidl);
            }
            // 复制 pidl 对象
            pidl = reinterpret_cast<PIDLIST_ABSOLUTE>(ILClone(other.pidl));
        }
        return *this;
    }

    // 移动赋值运算符用于转移其他对象的 PIDL
    PIDLFromPath& operator=(PIDLFromPath&& other) noexcept
    {
        if (this != &other)
        {
            // 释放当前 pidl 对象
            if (pidl)
            {
                ILFree(pidl);
            }
            // 移动 pidl 对象
            pidl = other.pidl;
            other.pidl = nullptr; // 避免被其他对象释放
        }
        return *this;
    }

    // 将 PIDL 转换为 PIDLIST_ABSOLUTE 类型,方便使用
    operator PIDLIST_ABSOLUTE() const { return pidl; }
};

// 定义用于存储 GUID 的全局变量
const GUID CLSID_TaskbandPin =
{
    0x90aa3a4e, 0x1cba, 0x4233,
    { 0xb8, 0xbb, 0x53, 0x57, 0x73, 0xd4, 0x84, 0x49}
};

const GUID IID_IPinnedList3 =
{
    0x0dd79ae2, 0xd156, 0x45d4,
    { 0x9e, 0xeb, 0x3b, 0x54, 0x97, 0x69, 0xe9, 0x40 }
};

// 定义枚举类型 PLMC
enum PLMC { PLMC_EXPLORER = 4 };

// 定义用于 COM 接口 IPinnedList3 的虚函数表结构体和接口
struct IPinnedList3Vtbl;
struct IPinnedList3 { IPinnedList3Vtbl* vtbl; };

// 定义用于释放 IPinnedList3 的函数指针类型
typedef ULONG STDMETHODCALLTYPE ReleaseFuncPtr(IPinnedList3* that);

// 定义用于修改 IPinnedList3 的函数指针类型
typedef HRESULT STDMETHODCALLTYPE ModifyFuncPtr(IPinnedList3* that,
    PCIDLIST_ABSOLUTE unpin, PCIDLIST_ABSOLUTE pin, PLMC caller);

// 定义 IPinnedList3 的虚函数表结构体
struct IPinnedList3Vtbl
{
    void* QueryInterface;
    void* AddRef;
    ReleaseFuncPtr* Release;
    void* MethodSlot4; void* MethodSlot5; void* MethodSlot6;
    void* MethodSlot7; void* MethodSlot8; void* MethodSlot9;
    void* MethodSlot10; void* MethodSlot11; void* MethodSlot12;
    void* MethodSlot13; void* MethodSlot14; void* MethodSlot15;
    void* MethodSlot16;
    ModifyFuncPtr* Modify;
};

// 定义错误信息字符串
wchar_t const* ERR_STR_USAGE = L"Usage:\n"
"  pin \"C:\\path\\to\\file.lnk\"      Pin a Shortcut.\n"
"  pin \"C:\\path\\to\\file.lnk\" u    Unpin a Shortcut.\n"
"\n"
"Exit codes: -1 = printed usage\n"
"             0 = succeeded\n"
"             1 = CoInitialize failed\n"
"             2 = ILCreateFromPathW failed\n"
"             3 = CoCreateInstance failed\n"
"             4 = IPinnedList3::Modify failed\n";
wchar_t const* ERR_STR_COINIT = L"CoInitialize failed.\n";
wchar_t const* ERR_STR_PIDL = L"ILCreateFromPathW failed.\n";
wchar_t const* ERR_STR_CREATE = L"CoCreateInstance failed.\n";
wchar_t const* ERR_STR_MODIFY = L"IPinnedList3::Modify failed.\n";

// 定义错误码
int const ERR_ID_USAGE = -1, ERR_ID_SUCCEEDED = 0,
ERR_ID_COINIT = 1, ERR_ID_PIDL = 2,
ERR_ID_CREATE = 3, ERR_ID_MODIFY = 4;

int wmain(int argc, const PWSTR* argv)
{
    // 检查参数数量是否正确
    if (argc < 2 || argc > 3)
    {
        fputws(ERR_STR_USAGE, stderr);
        return ERR_ID_USAGE;
    }

    // 检查是否需要固定或取消固定快捷方式
    const bool pinning = (argc != 3 || argv[2][0] != L'u');

    // 初始化 COM 环境
    CoInitializeGuard guard;
    if (!SUCCEEDED(guard))
    {
        fputws(ERR_STR_COINIT, stderr);
        return ERR_ID_COINIT;
    }

    // 根据路径创建 PIDL 对象
    PIDLFromPath pidl(argv[1]);
    if (!(bool)pidl)
    {
        fputws(ERR_STR_PIDL, stderr);
        return ERR_ID_PIDL;
    }

    // 创建 IPinnedList3 实例
    IPinnedList3* pinnedList;
    if (!SUCCEEDED(CoCreateInstance(
        CLSID_TaskbandPin, NULL, CLSCTX_ALL,
        IID_IPinnedList3, (LPVOID*)(&pinnedList))))
    {
        fputws(ERR_STR_CREATE, stderr);
        return ERR_ID_CREATE;
    }

    // 调用 Modify 方法进行固定或取消固定操作
    HRESULT hr = pinnedList->vtbl->Modify(pinnedList,
        pinning ? NULL : pidl,
        pinning ? pidl : NULL,
        PLMC_EXPLORER);

    // 释放对象
    pinnedList->vtbl->Release(pinnedList);

    // 检查操作是否成功
    if (!SUCCEEDED(hr))
    {
        fputws(ERR_STR_MODIFY, stderr);
        return ERR_ID_MODIFY;
    }

    // 操作成功,返回成功状态码
    return ERR_ID_SUCCEEDED;
}

目前,此代码有一个问题,即 Modify 方法是直接返回的(异步)。在 Modify 方法返回前,系统会弹出一个 Toast 应用通知以便于询问用户是否允许固定快捷方式。程序并不知道用户处理弹窗的结果。

只有当点击“是”才会固定到任务栏。所以,当通知设置被禁用或者 “应用” 通知被限制通知,我们就无法操作此选项卡。因此,必须检查通知设置是否正常。

有两种检查方式:一种是官方推荐的通过 ToastNotificationManager 类的 Setting 属性来获知状态(Raymond Chen 在一篇《Old New Thing》博文中详细介绍了这一点),然后使用 Shell 命令打开通知中心设置并指示用户启用通知。

另外一种就是 Github 上有人提供的讨巧方法(Enabling Toast Notifications)。这种方法设置注册表 ToastEnabled 值项为 1 并重启用户应用服务(WpnUserService)。以允许激活全局通知设置(但可能没办法重置针对局部应用的通知设置)。

注意:由于版本更新, WpnUserService 服务后面可能伴随内部版本号标记,例如,WpnUserService_4e3ab 作为新的服务名(此时,原始的 WpnUserService 服务的配置可能没有被彻底删除,但实际上已经没法启动):

真正有效的是下面的这个服务,它的名称随着系统版本而有所不同,所以代码必须枚举所有前缀为 WpnUserService 的服务(对名称恰好为 “WpnUserService” 的需要检查是否有效),并尝试启动 / 重启服务。

根据我的理解,可以通过下面代码实现激活全局通知设置:

// 启用应用通知服务.cpp : 此文件包含 "main" 函数。程序执行将在此处开始并结束。
//

#include <iostream>
#include <windows.h>
#include <string>
#include <vector>
#include <algorithm>

// 修改注册表项
bool ModifyRegistryKey(HKEY hKey, const std::wstring& subKey,
    const std::wstring& valueName, DWORD value) {
    LONG result;
    HKEY hSubKey;

    // 打开或创建注册表项
    result = RegCreateKeyEx(hKey, subKey.c_str(), 0, nullptr,
        REG_OPTION_NON_VOLATILE, KEY_WRITE, nullptr, &hSubKey, nullptr);
    if (result != ERROR_SUCCESS) {
        std::cerr << "Error opening or creating registry key." << std::endl;
        return false;
    }

    // 设置注册表项的值
    result = RegSetValueEx(hSubKey, valueName.c_str(), 0, REG_DWORD,
        reinterpret_cast<BYTE*>(&value), sizeof(value));
    if (result != ERROR_SUCCESS) {
        std::cerr << "Error setting registry value." << std::endl;
        RegCloseKey(hSubKey);
        return false;
    }

    // 关闭注册表项
    RegCloseKey(hSubKey);
    return true;
}

// 获取所有服务名称
std::vector<std::wstring> GetServicesNames() {
    std::vector<std::wstring> serviceNames;
    SC_HANDLE scmHandle = OpenSCManager(nullptr, nullptr, SC_MANAGER_ENUMERATE_SERVICE);
    if (!scmHandle) {
        std::cerr << "Error opening service control manager." << std::endl;
        return serviceNames;
    }

    DWORD bufferSize = 0;
    DWORD serviceCount = 0;
    EnumServicesStatusEx(scmHandle, SC_ENUM_PROCESS_INFO, SERVICE_WIN32,
        SERVICE_STATE_ALL, nullptr, 0, &bufferSize, &serviceCount, nullptr, nullptr);

    std::vector<BYTE> buffer(bufferSize, 0);
    ENUM_SERVICE_STATUS_PROCESS* serviceStatus =
        reinterpret_cast<ENUM_SERVICE_STATUS_PROCESS*>(buffer.data());

    if (!EnumServicesStatusEx(scmHandle, SC_ENUM_PROCESS_INFO, SERVICE_WIN32,
        SERVICE_STATE_ALL, reinterpret_cast<LPBYTE>(serviceStatus),
        bufferSize, &bufferSize, &serviceCount, nullptr, nullptr)) {
        std::cerr << "Error enumerating services." << std::endl;
        CloseServiceHandle(scmHandle);
        return serviceNames;
    }

    for (DWORD i = 0; i < serviceCount; ++i) {
        serviceNames.push_back(serviceStatus[i].lpServiceName);
    }

    CloseServiceHandle(scmHandle);
    return serviceNames;
}

// 获取服务状态
DWORD GetServiceStatus(const std::wstring& serviceName) {
    SC_HANDLE scmHandle = OpenSCManager(nullptr, nullptr, SC_MANAGER_CONNECT);
    if (!scmHandle) {
        std::cerr << "Error opening service control manager." << std::endl;
        return SERVICE_STOPPED;
    }

    SC_HANDLE serviceHandle = OpenService(scmHandle, serviceName.c_str(),
        SERVICE_QUERY_STATUS);
    if (!serviceHandle) {
        std::cerr << "Error opening service." << std::endl;
        CloseServiceHandle(scmHandle);
        return SERVICE_STOPPED;
    }

    SERVICE_STATUS serviceStatus;
    if (!QueryServiceStatus(serviceHandle, &serviceStatus)) {
        std::cerr << "Error querying service status." << std::endl;
        CloseServiceHandle(serviceHandle);
        CloseServiceHandle(scmHandle);
        return SERVICE_STOPPED;
    }

    CloseServiceHandle(serviceHandle);
    CloseServiceHandle(scmHandle);
    return serviceStatus.dwCurrentState;
}

// 启动或重新启动服务
bool StartOrRestartServiceByName(const std::wstring& serviceName) {
    DWORD serviceStatus = GetServiceStatus(serviceName);

    // 如果服务已经启动,则尝试重启服务
    if (serviceStatus == SERVICE_RUNNING || serviceStatus == SERVICE_START_PENDING) {
        SC_HANDLE scmHandle = OpenSCManager(nullptr, nullptr, SC_MANAGER_CONNECT);
        if (!scmHandle) {
            std::cerr << "Error opening service control manager." << std::endl;
            return false;
        }

        std::wcout << L"ServiceName: " << serviceName.c_str() << std::endl;

        SC_HANDLE serviceHandle = OpenService(scmHandle, serviceName.c_str(),
            SERVICE_START | SERVICE_STOP | SERVICE_QUERY_STATUS);
        if (!serviceHandle) {
            std::cerr << "Error opening service." << std::endl;
            CloseServiceHandle(scmHandle);
            return false;
        }

        SERVICE_STATUS serviceStatus;
        if (!ControlService(serviceHandle, SERVICE_CONTROL_STOP, &serviceStatus)) {
            std::cerr << "Error stopping service." << std::endl;
            CloseServiceHandle(serviceHandle);
            CloseServiceHandle(scmHandle);
            return false;
        }

        Sleep(1000); // Wait for a second for the service to stop

        if (!StartService(serviceHandle, 0, nullptr)) {
            std::cerr << "Error starting service." << std::endl;
            CloseServiceHandle(serviceHandle);
            CloseServiceHandle(scmHandle);
            return false;
        }

        CloseServiceHandle(serviceHandle);
        CloseServiceHandle(scmHandle);
        return true;
    }
    // 如果服务没有启动,则启动服务
    else if (serviceStatus == SERVICE_STOPPED || serviceStatus == SERVICE_STOP_PENDING) {
        SC_HANDLE scmHandle = OpenSCManager(nullptr, nullptr, SC_MANAGER_CONNECT);
        if (!scmHandle) {
            std::cerr << "Error opening service control manager." << std::endl;
            return false;
        }

        std::wcout << L"ServiceName: " << serviceName.c_str() << std::endl;

        SC_HANDLE serviceHandle = OpenService(scmHandle, serviceName.c_str(),
            SERVICE_START | SERVICE_QUERY_STATUS);
        if (!serviceHandle) {
            std::cerr << "Error opening service." << std::endl;
            CloseServiceHandle(scmHandle);
            return false;
        }

        if (!StartService(serviceHandle, 0, nullptr)) {
            std::cerr << "Error starting service." << std::endl;
            CloseServiceHandle(serviceHandle);
            CloseServiceHandle(scmHandle);
            return false;
        }

        CloseServiceHandle(serviceHandle);
        CloseServiceHandle(scmHandle);
        return true;
    }

    std::cerr << "Invalid service status." << std::endl;
    return false;
}


int main() {
    // 修改注册表项 ToastEnabled
    if (!ModifyRegistryKey(HKEY_CURRENT_USER,
        L"Software\\Microsoft\\Windows\\CurrentVersion\\PushNotifications",
        L"ToastEnabled", 1)) {
        std::cerr << "Failed to modify registry key." << std::endl;
        return 1;
    }

    // 获取所有服务名称
    std::vector<std::wstring> serviceNames = GetServicesNames();

    // 启动所有包含指定字符串的服务
    for (const auto& serviceName : serviceNames) {
        if (serviceName.find(L"WpnUserService") != std::wstring::npos) {
            // 启动或重新启动服务
            if (!StartOrRestartServiceByName(serviceName)) {
                std::cerr << "Failed to start or restart service." << std::endl;
                //return 1;
            }
        }
    }

    std::cout << "Toast notifications enabled successfully." << std::endl;
    //std::cin.get();
    return 0;
}

然而,就像手动设置那样,下面条件必须都满足才能使用户及时观察到弹窗:

而上面的方法不能够获取或控制局部的设置,如对来自 “应用” 的通知设置。

此外,修改并不总是有效,有时候 explorer 会陷入某种特殊的状态而不显示任何通知,即使设置正常。唯一的方法就是检测是否能够正常显示 Toast 通知,然后在失败时尝试重启 explorer 进程:

1. How to detect windows 10 toast notification triggered by another app using a UWP app [LinkHere].

2. Toast notification - How to check if it is visible [LinkHere].

四、获取并修改已固定项的列表顺序

既然我们能够通过多种方法固定/取消固定快捷方式,那么就一定会考虑获取已经固定项的顺序的方法。我查阅了很多资料,资料显示已经固定项同时存在注册表和磁盘存储。

对于磁盘存储,固定项是快捷方式,它们位于下面的路径:

%AppData%\Microsoft\Internet Explorer\Quick Launch\User Pinned\TaskBar

这是一个常规的、古老的路径。你可以在这里添加自己的快捷方式,但这是行不通的 —— 因为 explorer 不会更新列表。实际的列表由注册表存储管理,磁盘存储的 lnk 仅仅是图标等信息而已,explorer 在运行时结合注册表、内存结构和 TaskBar 文件夹三者而工作。

对于注册表项,位于注册表如下位置:

HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Explorer\Taskband

我们主要观察 Favorites 、FavoritesResolve、FavoritesRemovedChanges 这几项。

Favorites 包含 png 路径字符串,推测和图标有关,FavoritesResolve 和 lnk 快捷方式有关,包含TaskBar 文件夹中的路径;当删除 TaskBar 文件夹中某个 lnk 时重启资源管理器后任务栏中对应项变为无法解析的白色图标,当在重启 explorer 之前删除 FavoritesRemovedChanges 注册表值项,则可以成功移除这个空白项(Remove Chrome pinned items from Start Menu and Taskbar - #6 by stevezeeee)。

所以,一种比较原始的方法就是解析 FavoritesResolve 和 TaskBar 文件夹的信息,然后需要修改顺序时,则通过删除 Taskband 注册表项,并按照理想的顺序重新固定所有图标来完成(Unpin Edge (and pin Internet Explorer) with pure PowerShell)。

删除某个项目,则只要操作前删除 FavoritesRemovedChanges 注册表值项,再删除 TaskBar 文件夹中对应的 lnk ,最后重启 explorer 即可(删除所有 Chrome 快捷方式的 bat 脚本如下来自上面的 #6 by stevezeeee)。

DEL /F /S /Q /A "%AppData%\Microsoft\Internet Explorer\Quick Launch\User Pinned\TaskBar\*chrome*.lnk"
REG DELETE HKCU\Software\Microsoft\Windows\CurrentVersion\Explorer\Taskband\ /v FavoritesRemovedChanges /f 
taskkill /f /im explorer.exe && start explorer.exe

但是这种方法不是直接的编程方法,不仅逐一比对解析列表显得十分耗时,而且此方法不支持对 UWP 类应用快捷方式的解析(UWP 应用的快捷方式不在 Taskbar 文件夹中)。在修改顺序时,需要重建整个 Taskband 注册表,这是很糟糕的。就目前情况而言,没有公开资料解释这里的二进制数据格式,尽管它从 Win7 就存在了。

除非研究清楚这里的二进制数据格式,一般地只能老实调用组策略 GPO 并结合 XML 配置文件来完成修改。(在有限版本下可以不使用注册表和组策略,而是可以利用 COM 接口修改任务栏固定项列表,具体见小节三和小节五)

五、目前已知的开源解决方案

我在 Github 上找到了 adamecrAppSwitcherBar (应用切换栏 -- 任务栏增强)项目,使用了 Gee Law 的文章中介绍的 COM 接口来枚举固定到任务栏的快捷方式并计算它们的顺序,该方法支持对 UWP 应用的解析。此外项目还通过 CLSID_StartLayoutCmdlet 接口对开始菜单的快捷方式的获取和布局修改。这是一个很好的例子,但是它是 C# 的,我准备将它移植到 C++。

这里查看 PinsService::GetTaskbarPinnedApplications 代码(链接)。

下文摘录自作者自述文本:

​获取有关固定到任务栏的应用程序的信息似乎有点棘手。最简单的方法是从 AppData\Roaming\Microsoft\Internet Explorer\Quick Launch\User Pinned\TaskBar 获取 (.lnk) 链接。尝试这种方法时,我很难获得引脚的顺序。固定应用程序的列表也存储在 HKCU\Software\Microsoft\Windows\CurrentVersion\Explorer\Taskband\ 注册表值 Favorites 中——使用没有记录格式的二进制值。由于注册表值中的信息显然顺序正确,因此我尝试查找链接文件的名称,然后按二进制注册表值中的名称位置对链接(从上述目录检索)进行排序。不幸的是,此方法不适用于 Store/UWP 应用程序。在这种情况下,快速启动文件夹中不会创建任何 .lnk 文件,但我用于获取链接顺序的注册表值包含有关 UWP 应用程序的信息。我尝试以某种方式提取固定商店应用程序的 AppId 以及有关引脚顺序的信息,但我没有找到可靠的方法,只能对已安装的应用程序列表进行非常缓慢的一一检查。所以我放弃了使用这个方法。

我在 Gee Law 的有趣 文章 中发现了本机接口 IPinnedList3 和相关的 COM 类。由于该界面没有记录,并且互联网上几乎没有其他信息来源,我不得不尝试并失败了一点,但最终使其按需要工作。

简化算法如下,真正的实现在 PinsService::GetTaskbarPinnedApplications:

//get the COM class type for {90aa3a4e-1cba-4233-b8bb-535773d48449}
var objType = Type.GetTypeFromCLSID(new Guid(Win32Consts.CLSID_TaskbanPin), false);
//create an instance of COM class
var obj = Activator.CreateInstance(objType);
//get the IPinnedList3 interface
var pinnedList=obj as IPinnedList3;
//get the enumerator
var hrs = pinnedList.EnumObjects(out var iel);
do
{
   hrs = iel.Next(1, out var pidl, out _);
   if (!hrs.IsS_OK) break;

   hrs = Shell32.SHCreateItemFromIDList(pidl, iShellItem2Guid, out var shellItem);
   if (!hrs.IsS_OK || shellItem == null) break;

   //get the information from shell item representing either 
   //link to desktop app or directly Store/UWP app
   ...

   Marshal.FreeCoTaskMem(pidl);
} while (hrs.IsS_OK);

如上所述,该界面不是公开的且没有文档记录(并且也有版本控制),因此它可能会随着某些 Windows 更新而更改。

开始菜单 Pins 解析效果: 

任务栏 Pins 解析效果:

六、通过 WinRT API 受限控制任务栏和开始菜单

(本小节于 2024 / 05 / 20 追加) 

微软提供了 TaskbarManager 类 来控制任务栏快捷方式,但是必须以 SDK 16299 为目标并运行版本 16299 或更高版本才能使用任务栏 API,且访问这个接口需要向微软申请解锁令牌(下面提供的讨论中有人提供了绕过申请直接生成令牌的方法)。

官方的方法(LAF 访问令牌申请表):

绕过技巧: 

几年前我写过如何生成自己的令牌(Generating valid tokens to access Limited Access Features in Windows 10 | Rafael Rivera)。

欢迎您在 https://withinrafael.com/api/windows_laf_token 在线使用我的令牌生成器。 

(功能列表是截至 Windows vNext 25997 的最新功能。无 SLA 。)

虽然这个类支持检查任务栏是否允许固定,当前 APP 是否已经固定,固定或者取消固定,但是根据 Github 上已经使用的开发者所说,目前这个 API 只能访问自己的 APP 而无法操作其他 APP。

链接:Support TaskbarManager (pinning to taskbar) from desktop apps

此外,微软还提供了 StartMenuPrimaryTile API 来控制开始菜单固定项和磁贴的设置,但必须以 SDK 15063 为目标并运行版本 15063 或更高版本才能使用主要磁贴 API。


参考文献

1.用代码实现PIN到任务栏(1) | Study Notes (yhsnlkm.github.io)

2.Microsoft Edge (Chromium) the case of Taskbar pinning

3.c# - How to find Toast notifications are enabled? - Stack Overflow

4.Enabling Toast Notifications · Issue #3 · fox-it/Invoke-CredentialPhisher · GitHub

5.The Old New Thing

6.DesktopToastsSample

7.Remove Chrome pinned items from Start Menu and Taskbar

8.GitHub - adamecr/AppSwitcherBar

9.Windows 7 Taskbar Icons

10.How to PIN MSEdge to taskbar with batch file

11.Unpin Edge (and pin Internet Explorer) with pure PowerShell

12.windows 7 - Taskbar icon for all users

13.How to pin an application to the taskbar

14.How to manage Windows Taskbar Items pinning using Group Policy

15.(any more...)


原文出处链接:https://blog.csdn.net/qq_59075481/article/details/139028308

转载请注明出处。

本文发布于:2024.05.18,更新于:2024.05.20.


网站公告

今日签到

点亮在社区的每一天
去签到