Закрепляем ярлыки на начальном экране в Windows 10 у текущего пользователя

  • Tutorial

Эта статья — своего рода 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 за помощь в реализации метода.

Комментарии 12

    –1
    Крутяк, побольше объяснения кода не помешало и комментариев в нём и цены тебе не было бы.
      0
      Хм, а какое конкретно место не понятно?
        0
        Более менее сейчас разобрался, но так и не понял как извлекается строк из shell32.dll используя его номер WinAPI и почему для PowerShell он не извлекается?
        Для наглядности можно еще добавить с ярлыками программ которые не входят в стандартный софт.
          0
          В каком смысле «как извлекается строк из shell32.dll»? Номер не WinAPI, а код (не номер) строки локали в shell32.dll. Как самому узнать все коды предложений, я указал.
          почему для PowerShell он не извлекается?

          Не извлекается что?..
      0
      Хм… неужели этим меню кто то пользуется.
        +1
        Безумно удобно иметь огромные плитки (ну, можно и маленькие), не прицеливаясь каждый раз, желая что-то запустить. Ну, и поиск в Пуске никто не отменял. Самый связка вышла: Пуск и начальный экран.
        image
          0
          Ну не знаю… Я конечно понимаю что это дело вкусовщины, но сколько не пытался к этому меню привыкнуть — ничего не вышло. Собственно как и у моего окружения. В итоге использую StartIsBack. Как итог смесь панелей win7 и xp



            0
            Полностью согласен. Я же не истина в последней инстанции, но мне правда очень нравится, как меняется 10. Я не люблю прицеливаться к иконкам, нравится все огромное, ибо моники сейчас огромные. И экономить место в угоду, по мне, непонятно чему, не вижу смысла. И древовидное отображение папок а-ля XP мне не нравится уже (хотя очевидно, как и все, я тащился, когда вышла XP). Хотя еще года 3 назад я тоже ставил StartIsBack (купленный), но после релиза 1809 «для меня» все исправили, и я стал использовать встроенный. КПД повысилилось.
              0
              Да, полностью солидарен что дело вкуса. Да и в Win8 было все ужасно c плиточным пуском, но в Win 10 сделали пуск сносный, не без богов конечно. Я за то чтобы меньше использовать — сторонний софт, пользуюсь только PowerShell чтобы сделать удобную тебе систему)
                0
                Не только вкуса, но ещё и быстроты. Что мы имеем тут — если для всего требуемого софта не хватило места на первом экране, то добро пожаловать во «все программы».

                В то время как плитки позволили мне уместить все приложения на первом экране.
          0
          А какая практическая ценность задачи?
          Разве что вы — сисадмин и нужно на 500 офисных компах добавить ярлык нового приложения
            0
            Для админов есть Import-StartLayout. А ценность описана в самом начале статьи.

            Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

            Самое читаемое