Эта статья — своего рода proof of concept, как можно закрепить программно (открепить) ярлык на начальном экране для текущего пользователя без перезапуска или выхода из учетной записи. Как вы знаете, с выходом Windows 10 October 2018 Microsoft без шума закрыл доступ к API открепления (закрепления) ярлыков от начального экрана и панели задач: отныне это можно сделать лишь вручную.
Ниже приведен пример кода для закрепления (открепления) ярлыка на начальный экран, который когда-то работал. Как можете видеть, в коде используется метод получения локализорованной строки, и для этого нам необходимо знать код строки, чтобы вызвать соответствующий пункт контекстного меню. В данном пример, чтобы закрепить ярлык командной строки, мы вызываем строку с кодом 51201, «Закрепить на начальном экране», из библиотеки %SystemRoot%\system32\shell32.dll.
Получить список всех локализованных строк удобнее всего через стороннюю утилиту ResourcesExtract.
Попытка закрепить ярлык командной строки устаревшим методом
# Extract a localized string from shell32.dll
$Signature = @{
Namespace = "WinAPI"
Name = "GetStr"
Language = "CSharp"
MemberDefinition = @"
[DllImport("kernel32.dll", CharSet = CharSet.Auto)]
public static extern IntPtr GetModuleHandle(string lpModuleName);
[DllImport("user32.dll", CharSet = CharSet.Auto)]
internal static extern int LoadString(IntPtr hInstance, uint uID, StringBuilder lpBuffer, int nBufferMax);
public static string GetString(uint strId)
{
IntPtr intPtr = GetModuleHandle("shell32.dll");
StringBuilder sb = new StringBuilder(255);
LoadString(intPtr, strId, sb, sb.Capacity);
return sb.ToString();
}
"@
}
if (-not ("WinAPI.GetStr" -as [type]))
{
Add-Type @Signature -Using System.Text
}
# Pin to Start: 51201
# Unpin from Start: 51394
$LocalizedString = [WinAPI.GetStr]::GetString(51201)
# Trying to pin the Command Prompt shortcut to Start
$Target = Get-Item -Path "$env:APPDATA\Microsoft\Windows\Start Menu\Programs\System Tools\Command Prompt.lnk"
$Shell = New-Object -ComObject Shell.Application
$Folder = $Shell.NameSpace($Target.DirectoryName)
$file = $Folder.ParseName($Target.Name)
$Verb = $File.Verbs() | Where-Object -FilterScript {$_.Name -eq $LocalizedString}
$Verb.DoIt()
Сейчас консоль вываливается с ошибкой Access is denied. (Exception from HRESULT: 0x80070005 (E_ACCESSDENIED))
Хотя, как можно заметить, API, конечно, отдает глагол контекстного меню «Закрепить на начальном &экране», но не может его выполнить.
Где-то читал, что, возможно, Microsoft заблокировал доступ в целях недопущения закрепления ярлыков bloatware. Звучит странно, но ладно…
Я уже много лет поддерживаю крупнейший PowerShell-модуль для тонкой настройки Windows 10 и автоматизации рутинных задач. Подробнее можно почитать здесь. И встала задача из спортивного интереса обойти это ограничение и попробовать закрепить нужные мне ярлыки. Речь, конечно, идет по большей части о домашних пользователях, ведь в энтерпрайзе используется GPO для импорта предзаготовленного макета начального экрана и панели задач.
Мы знаем, что текущий макет начального экрана можно выгрузить в формате XML. Но даже, если его настроить должным образом, импортировать макет в профиль текущего пользователя не получится: Import-StartLayout -LayoutPath "D:\Layout.xml импортирует макеты начального экрана и панели задач только для новых пользователей.
Идея заключается в том, чтобы использовать политику «Макет начального экрана» (Prevent users from customizing their Start Screen), отвечающую за подгрузку предзаготовленного макета в формате XML из определенного места. Соответственно, наш хак будет состоять из следующих пунктов:
Выгружаем текущий макет начального экрана;
Парсим XML, добавляя необходимые нам ярлыки (ссылки должны вести на реально существующие ярлыки) и сохраняем;
С помощью политики временно выключаем возможность редактировать макет начального экрана;
Перезапускаем меню «Пуск»;
Программно открываем меню «Пуск», чтобы в реестре сохранился его макет;
Выключаем политику, чтобы можно было редактировать макет начального экрана;
И открываем меню «Пуск» опять.
Вуаля! В данном примере мы настроили начальный экран на лету, закрепив на него три ярлыка: Панель управления, устройства и принтеры и PowerShell.
Код целиком
<#
.SYNOPSIS
Configure the Start tiles
.PARAMETER ControlPanel
Pin the "Control Panel" shortcut to Start
.PARAMETER DevicesPrinters
Pin the "Devices & Printers" shortcut to Start
.PARAMETER PowerShell
Pin the "Windows PowerShell" shortcut to Start
.PARAMETER UnpinAll
Unpin all the Start tiles
.EXAMPLE
.\Pin.ps1 -Tiles ControlPanel, DevicesPrinters, PowerShell
.EXAMPLE
.\Pin.ps1 -UnpinAll
.EXAMPLE
.\Pin.ps1 -UnpinAll -Tiles ControlPanel, DevicesPrinters, PowerShell
.EXAMPLE
.\Pin.ps1 -UnpinAll -Tiles ControlPanel
.EXAMPLE
.\Pin.ps1 -Tiles ControlPanel -UnpinAll
.LINK
https://github.com/farag2/Windows-10-Sophia-Script
.NOTES
Separate arguments with comma
Current user
#>
[CmdletBinding()]
param
(
[Parameter(
Mandatory = $false,
Position = 0
)]
[switch]
$UnpinAll,
[Parameter(
Mandatory = $false,
Position = 1
)]
[ValidateSet("ControlPanel", "DevicesPrinters", "PowerShell")]
[string[]]
$Tiles,
[string]
$StartLayout = "$PSScriptRoot\StartLayout.xml"
)
begin
{
# Unpin all the Start tiles
if ($UnpinAll)
{
Export-StartLayout -Path $StartLayout -UseDesktopApplicationID
[xml]$XML = Get-Content -Path $StartLayout -Encoding UTF8 -Force
$Groups = $XML.LayoutModificationTemplate.DefaultLayoutOverride.StartLayoutCollection.StartLayout.Group
foreach ($Group in $Groups)
{
# Removing all groups inside XML
$Group.ParentNode.RemoveChild($Group) | Out-Null
}
$XML.Save($StartLayout)
}
}
process
{
# Extract strings from shell32.dll using its' number
$Signature = @{
Namespace = "WinAPI"
Name = "GetStr"
Language = "CSharp"
MemberDefinition = @"
[DllImport("kernel32.dll", CharSet = CharSet.Auto)]
public static extern IntPtr GetModuleHandle(string lpModuleName);
[DllImport("user32.dll", CharSet = CharSet.Auto)]
internal static extern int LoadString(IntPtr hInstance, uint uID, StringBuilder lpBuffer, int nBufferMax);
public static string GetString(uint strId)
{
IntPtr intPtr = GetModuleHandle("shell32.dll");
StringBuilder sb = new StringBuilder(255);
LoadString(intPtr, strId, sb, sb.Capacity);
return sb.ToString();
}
"@
}
if (-not ("WinAPI.GetStr" -as [type]))
{
Add-Type @Signature -Using System.Text
}
# Extract the localized "Devices and Printers" string from shell32.dll
$DevicesPrinters = [WinAPI.GetStr]::GetString(30493)
# We need to get the AppID because it's auto generated
$Script:DevicesPrintersAppID = (Get-StartApps | Where-Object -FilterScript {$_.Name -eq $DevicesPrinters}).AppID
$Parameters = @(
# Control Panel hash table
@{
# Special name for Control Panel
Name = "ControlPanel"
Size = "2x2"
Column = 0
Row = 0
AppID = "Microsoft.Windows.ControlPanel"
},
# "Devices & Printers" hash table
@{
# Special name for "Devices & Printers"
Name = "DevicesPrinters"
Size = "2x2"
Column = 2
Row = 0
AppID = $Script:DevicesPrintersAppID
},
# Windows PowerShell hash table
@{
# Special name for Windows PowerShell
Name = "PowerShell"
Size = "2x2"
Column = 4
Row = 0
AppID = "{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}\WindowsPowerShell\v1.0\powershell.exe"
}
)
# Valid columns to place tiles in
$ValidColumns = @(0, 2, 4)
[string]$StartLayoutNS = "http://schemas.microsoft.com/Start/2014/StartLayout"
# Add pre-configured hastable to XML
function Add-Tile
{
param
(
[string]
$Size,
[int]
$Column,
[int]
$Row,
[string]
$AppID
)
[string]$elementName = "start:DesktopApplicationTile"
[Xml.XmlElement]$Table = $xml.CreateElement($elementName, $StartLayoutNS)
$Table.SetAttribute("Size", $Size)
$Table.SetAttribute("Column", $Column)
$Table.SetAttribute("Row", $Row)
$Table.SetAttribute("DesktopApplicationID", $AppID)
$Table
}
if (-not (Test-Path -Path $StartLayout))
{
# Export the current Start layout
Export-StartLayout -Path $StartLayout -UseDesktopApplicationID
}
[xml]$XML = Get-Content -Path $StartLayout -Encoding UTF8 -Force
foreach ($Tile in $Tiles)
{
switch ($Tile)
{
ControlPanel
{
$ControlPanel = [WinAPI.GetStr]::GetString(12712)
Write-Verbose -Message ("The `"{0}`" shortcut is being pinned to Start" -f $ControlPanel) -Verbose
}
DevicesPrinters
{
$DevicesPrinters = [WinAPI.GetStr]::GetString(30493)
Write-Verbose -Message ("The `"{0}`" shortcut is being pinned to Start" -f $DevicesPrinters) -Verbose
# Create the old-style "Devices and Printers" shortcut in the Start menu
$Shell = New-Object -ComObject Wscript.Shell
$Shortcut = $Shell.CreateShortcut("$env:APPDATA\Microsoft\Windows\Start menu\Programs\System Tools\$DevicesPrinters.lnk")
$Shortcut.TargetPath = "control"
$Shortcut.Arguments = "printers"
$Shortcut.IconLocation = "$env:SystemRoot\system32\DeviceCenter.dll"
$Shortcut.Save()
Start-Sleep -Seconds 3
}
PowerShell
{
Write-Verbose -Message ("The `"{0}`" shortcut is being pinned to Start" -f "Windows PowerShell") -Verbose
}
}
$Parameter = $Parameters | Where-Object -FilterScript {$_.Name -eq $Tile}
$Group = $XML.LayoutModificationTemplate.DefaultLayoutOverride.StartLayoutCollection.StartLayout.Group | Where-Object -FilterScript {$_.Name -eq "Sophia Script"}
# If the "Sophia Script" group exists in Start
if ($Group)
{
$DesktopApplicationID = ($Parameters | Where-Object -FilterScript {$_.Name -eq $Tile}).AppID
if (-not ($Group.DesktopApplicationTile | Where-Object -FilterScript {$_.DesktopApplicationID -eq $DesktopApplicationID}))
{
# Calculate current filled columns
$CurrentColumns = @($Group.DesktopApplicationTile.Column)
# Calculate current free columns and take the first one
$Column = (Compare-Object -ReferenceObject $ValidColumns -DifferenceObject $CurrentColumns).InputObject | Select-Object -First 1
# If filled cells contain desired ones assign the first free column
if ($CurrentColumns -contains $Parameter.Column)
{
$Parameter.Column = $Column
}
$Group.AppendChild((Add-Tile @Parameter)) | Out-Null
}
}
else
{
# Create the "Sophia Script" group
[Xml.XmlElement]$Group = $XML.CreateElement("start:Group", $StartLayoutNS)
$Group.SetAttribute("Name","Sophia Script")
$Group.AppendChild((Add-Tile @Parameter)) | Out-Null
$XML.LayoutModificationTemplate.DefaultLayoutOverride.StartLayoutCollection.StartLayout.AppendChild($Group) | Out-Null
}
}
$XML.Save($StartLayout)
}
end
{
# Temporarily disable changing the Start menu layout
if (-not (Test-Path -Path HKCU:\SOFTWARE\Policies\Microsoft\Windows\Explorer))
{
New-Item -Path HKCU:\SOFTWARE\Policies\Microsoft\Windows\Explorer -Force
}
New-ItemProperty -Path HKCU:\SOFTWARE\Policies\Microsoft\Windows\Explorer -Name LockedStartLayout -Value 1 -Force
New-ItemProperty -Path HKCU:\SOFTWARE\Policies\Microsoft\Windows\Explorer -Name StartLayoutFile -Value $StartLayout -Force
Start-Sleep -Seconds 3
# Restart the Start menu
Stop-Process -Name StartMenuExperienceHost -Force -ErrorAction Ignore
Start-Sleep -Seconds 3
# Open the Start menu to load the new layout
$wshell = New-Object -ComObject WScript.Shell
$wshell.SendKeys("^{ESC}")
Start-Sleep -Seconds 3
# Enable changing the Start menu layout
Remove-ItemProperty -Path HKCU:\SOFTWARE\Policies\Microsoft\Windows\Explorer -Name LockedStartLayout -Force -ErrorAction Ignore
Remove-ItemProperty -Path HKCU:\SOFTWARE\Policies\Microsoft\Windows\Explorer -Name StartLayoutFile -Force -ErrorAction Ignore
Remove-Item -Path $StartLayout -Force
Stop-Process -Name StartMenuExperienceHost -Force -ErrorAction Ignore
Start-Sleep -Seconds 3
# Open the Start menu to load the new layout
$wshell = New-Object -ComObject WScript.Shell
$wshell.SendKeys("^{ESC}")
}
Страница GitHub Windows 10 Sophia Script, где в том числе используется данный метод.
Огромное спасибо iNNOKENTIY21 за помощь в реализации метода.