Skip to content

Commit b89cfcb

Browse files
committed
feat(scripts): automatic font installer script
1 parent 21d9904 commit b89cfcb

4 files changed

Lines changed: 559 additions & 114 deletions

File tree

Lines changed: 334 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,334 @@
1+
Using module ..\common\Environment.psm1
2+
Using module ..\common\Logging.psm1
3+
Using module ..\common\Utils.psm1
4+
Using module ..\common\Scope.psm1
5+
Using module ..\common\Registry.psm1
6+
Using module ..\common\Ensure.psm1
7+
Using module ..\common\Exit.psm1
8+
Using module ..\common\Blob.psm1
9+
10+
<#
11+
.SYNOPSIS
12+
Downloads and installs fonts from an Azure Blob Storage container.
13+
14+
.DESCRIPTION
15+
Lists all font files (.ttf, .otf, .ttc) in a specified Azure Blob container,
16+
downloads them if not already cached locally (using MD5 hash comparison),
17+
and installs any fonts that are not already installed system-wide.
18+
19+
.PARAMETER StorageBlobUrl
20+
The URL to the Azure Blob Storage container (e.g., https://account.blob.core.windows.net/fonts).
21+
22+
.PARAMETER StorageBlobSasToken
23+
The SAS token for accessing the blob container.
24+
25+
.EXAMPLE
26+
.\Install-FontsFromBlob.ps1 -StorageBlobUrl 'https://myaccount.blob.core.windows.net/fonts' -StorageBlobSasToken 'sv=2021-06-08&ss=b&srt=co&sp=rl&se=...'
27+
#>
28+
29+
[CmdletBinding()]
30+
param(
31+
[Parameter(Mandatory)]
32+
[ValidateNotNullOrEmpty()]
33+
[String]$StorageBlobUrl,
34+
35+
[Parameter(Mandatory)]
36+
[ValidateNotNullOrEmpty()]
37+
[String]$StorageBlobSasToken
38+
)
39+
40+
[String]$Script:FontCacheFolder = $env:ProgramData | Join-Path -ChildPath 'AMT' | Join-Path -ChildPath 'Fonts' | Join-Path -ChildPath 'Cache';
41+
[String]$Script:FontsFolder = $env:windir | Join-Path -ChildPath 'Fonts';
42+
[String]$Script:FontsRegistryPath = 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Fonts';
43+
[String[]]$Script:SupportedExtensions = @('.ttf', '.otf', '.ttc');
44+
45+
# P/Invoke for font registration
46+
$Script:FontApiDefinition = @'
47+
[DllImport("gdi32.dll", CharSet = CharSet.Auto)]
48+
public static extern int AddFontResource(string lpszFilename);
49+
50+
[DllImport("user32.dll", CharSet = CharSet.Auto)]
51+
public static extern int SendMessage(IntPtr hWnd, uint Msg, IntPtr wParam, IntPtr lParam);
52+
'@;
53+
54+
function Get-FontFile {
55+
[CmdletBinding()]
56+
[OutputType([System.IO.FileInfo])]
57+
param(
58+
[Parameter(Mandatory)]
59+
[String]$BlobName,
60+
61+
[Parameter(Mandatory)]
62+
[String]$ContainerUrl,
63+
64+
[Parameter(Mandatory)]
65+
[String]$SasQueryString
66+
)
67+
68+
begin { Enter-Scope; }
69+
end { Exit-Scope -ReturnValue $Local:FontFile; }
70+
71+
process {
72+
[String]$Local:EncodedBlobName = [URI]::EscapeDataString($BlobName) -replace '%2F', '/';
73+
[String]$Local:BlobUri = "${ContainerUrl}/${Local:EncodedBlobName}?${SasQueryString}";
74+
75+
[String]$Local:MD5 = Get-BlobMD5 -BlobUri:$Local:BlobUri;
76+
77+
[System.IO.FileInfo]$Local:FontFile = $null;
78+
79+
if ($Local:MD5) {
80+
$Local:FontFile = Find-FileByHash -Hash:$Local:MD5 -Path:$Script:FontCacheFolder;
81+
}
82+
83+
if ($Local:FontFile) {
84+
Invoke-Info "Using cached file for $BlobName";
85+
return $Local:FontFile;
86+
}
87+
88+
[String]$Local:FileName = [System.IO.Path]::GetFileName($BlobName);
89+
[String]$Local:OutPath = $Script:FontCacheFolder | Join-Path -ChildPath $Local:FileName;
90+
91+
# If file exists but hash didn't match (or no hash available), use unique name
92+
if (Test-Path -Path $Local:OutPath) {
93+
[String]$Local:BaseName = [System.IO.Path]::GetFileNameWithoutExtension($Local:FileName);
94+
[String]$Local:Extension = [System.IO.Path]::GetExtension($Local:FileName);
95+
[String]$Local:Unique = [System.IO.Path]::GetFileNameWithoutExtension([System.IO.Path]::GetRandomFileName());
96+
$Local:OutPath = $Script:FontCacheFolder | Join-Path -ChildPath "${Local:BaseName}_${Local:Unique}${Local:Extension}";
97+
}
98+
99+
Invoke-Info "Downloading $BlobName...";
100+
101+
try {
102+
Invoke-RestMethod -Uri:$Local:BlobUri -Method:GET -OutFile:$Local:OutPath;
103+
Unblock-File -Path $Local:OutPath;
104+
} catch {
105+
Invoke-FailedExit -ErrorRecord $_ -ExitCode $Script:ERROR_BLOB_DOWNLOAD_FAILED -FormatArgs @($BlobName);
106+
}
107+
108+
return [System.IO.FileInfo]::new($Local:OutPath);
109+
}
110+
}
111+
112+
function Test-FontInstalled {
113+
[CmdletBinding()]
114+
[OutputType([Boolean])]
115+
param(
116+
[Parameter(Mandatory)]
117+
[String]$FontFileName
118+
)
119+
120+
begin { Enter-Scope; }
121+
end { Exit-Scope -ReturnValue $Local:IsInstalled; }
122+
123+
process {
124+
[String]$Local:InstalledPath = $Script:FontsFolder | Join-Path -ChildPath $FontFileName;
125+
[Boolean]$Local:FileExists = Test-Path -Path $Local:InstalledPath;
126+
127+
if (-not $Local:FileExists) {
128+
Invoke-Debug "Font file $FontFileName not found in Fonts folder.";
129+
return $false;
130+
}
131+
132+
# Check registry for any entry pointing to this filename
133+
[Boolean]$Local:InRegistry = $false;
134+
$Local:RegistryValues = Get-ItemProperty -Path $Script:FontsRegistryPath -ErrorAction SilentlyContinue;
135+
136+
if ($Local:RegistryValues) {
137+
foreach ($Local:Property in $Local:RegistryValues.PSObject.Properties) {
138+
if ($Local:Property.Value -eq $FontFileName) {
139+
$Local:InRegistry = $true;
140+
break;
141+
}
142+
}
143+
}
144+
145+
[Boolean]$Local:IsInstalled = $Local:FileExists -and $Local:InRegistry;
146+
Invoke-Debug "Font $FontFileName installed check: FileExists=$Local:FileExists, InRegistry=$Local:InRegistry";
147+
return $Local:IsInstalled;
148+
}
149+
}
150+
151+
function Get-FontDisplayName {
152+
[CmdletBinding()]
153+
[OutputType([String])]
154+
param(
155+
[Parameter(Mandatory)]
156+
[System.IO.FileInfo]$FontFile
157+
)
158+
159+
begin { Enter-Scope; }
160+
end { Exit-Scope -ReturnValue $Local:DisplayName; }
161+
162+
process {
163+
[String]$Local:DisplayName = $null;
164+
165+
# Try to get font name via Shell COM object
166+
try {
167+
$Local:Shell = New-Object -ComObject Shell.Application;
168+
$Local:Folder = $Local:Shell.Namespace($FontFile.DirectoryName);
169+
$Local:Item = $Local:Folder.ParseName($FontFile.Name);
170+
171+
# Property 21 is the Title/Name
172+
$Local:DisplayName = $Local:Folder.GetDetailsOf($Local:Item, 21);
173+
174+
if ([String]::IsNullOrWhiteSpace($Local:DisplayName)) {
175+
# Fallback to property 0 (Name without extension typically)
176+
$Local:DisplayName = $Local:Folder.GetDetailsOf($Local:Item, 0);
177+
}
178+
} catch {
179+
Invoke-Debug "Failed to get font name via Shell: $_";
180+
} finally {
181+
if ($Local:Shell) {
182+
[System.Runtime.InteropServices.Marshal]::ReleaseComObject($Local:Shell) | Out-Null;
183+
}
184+
}
185+
186+
# Fallback to filename-based name
187+
if ([String]::IsNullOrWhiteSpace($Local:DisplayName)) {
188+
$Local:DisplayName = [System.IO.Path]::GetFileNameWithoutExtension($FontFile.Name);
189+
}
190+
191+
# Determine font type suffix
192+
[String]$Local:Extension = $FontFile.Extension.ToLowerInvariant();
193+
[String]$Local:TypeSuffix = switch ($Local:Extension) {
194+
'.ttf' { ' (TrueType)' }
195+
'.ttc' { ' (TrueType)' }
196+
'.otf' { ' (OpenType)' }
197+
default { ' (TrueType)' }
198+
};
199+
200+
# Add suffix if not already present
201+
if (-not ($Local:DisplayName -match '\(TrueType\)|\(OpenType\)')) {
202+
$Local:DisplayName += $Local:TypeSuffix;
203+
}
204+
205+
return $Local:DisplayName;
206+
}
207+
}
208+
209+
function Install-Font {
210+
[CmdletBinding()]
211+
param(
212+
[Parameter(Mandatory)]
213+
[System.IO.FileInfo]$FontFile
214+
)
215+
216+
begin { Enter-Scope; }
217+
end { Exit-Scope; }
218+
219+
process {
220+
[String]$Local:FileName = $FontFile.Name;
221+
[String]$Local:DestinationPath = $Script:FontsFolder | Join-Path -ChildPath $Local:FileName;
222+
223+
Invoke-Info "Installing font: $Local:FileName";
224+
225+
# Copy font to Fonts folder
226+
try {
227+
Copy-Item -Path $FontFile.FullName -Destination $Local:DestinationPath -Force;
228+
} catch {
229+
Invoke-FailedExit -ErrorRecord $_ -ExitCode $Script:ERROR_FONT_COPY_FAILED -FormatArgs @($Local:FileName);
230+
}
231+
232+
# Get display name for registry
233+
[String]$Local:DisplayName = Get-FontDisplayName -FontFile:$FontFile;
234+
235+
# Register in registry
236+
try {
237+
Set-RegistryKey -Path $Script:FontsRegistryPath -Key $Local:DisplayName -Value $Local:FileName -Kind String;
238+
} catch {
239+
Invoke-FailedExit -ErrorRecord $_ -ExitCode $Script:ERROR_FONT_REGISTRY_FAILED -FormatArgs @($Local:FileName);
240+
}
241+
242+
# Load font into current session using P/Invoke
243+
try {
244+
$null = $Script:FontApi::AddFontResource($Local:DestinationPath);
245+
} catch {
246+
Invoke-Warn "Failed to load font immediately (may require reboot): $_";
247+
}
248+
249+
Invoke-Info "Installed font: $Local:DisplayName";
250+
}
251+
}
252+
253+
function Send-FontChangeNotification {
254+
begin { Enter-Scope; }
255+
end { Exit-Scope; }
256+
257+
process {
258+
# WM_FONTCHANGE = 0x001D, HWND_BROADCAST = 0xFFFF
259+
[IntPtr]$Local:HWND_BROADCAST = [IntPtr]::new(0xFFFF);
260+
[UInt32]$Local:WM_FONTCHANGE = 0x001D;
261+
262+
try {
263+
$null = $Script:FontApi::SendMessage($Local:HWND_BROADCAST, $Local:WM_FONTCHANGE, [IntPtr]::Zero, [IntPtr]::Zero);
264+
Invoke-Debug 'Sent WM_FONTCHANGE broadcast.';
265+
} catch {
266+
Invoke-Warn "Failed to broadcast font change notification: $_";
267+
}
268+
}
269+
}
270+
271+
Invoke-RunMain $PSCmdlet {
272+
Invoke-EnsureAdministrator;
273+
274+
# Register exit codes
275+
$Script:ERROR_BLOB_DOWNLOAD_FAILED = Register-ExitCode -Description 'Failed to download blob {0}';
276+
$Script:ERROR_FONT_COPY_FAILED = Register-ExitCode -Description 'Failed to copy font {0} to Fonts folder';
277+
$Script:ERROR_FONT_REGISTRY_FAILED = Register-ExitCode -Description 'Failed to register font {0} in registry';
278+
279+
# Add P/Invoke type
280+
try {
281+
$Script:FontApi = Add-Type -MemberDefinition $Script:FontApiDefinition -Name 'FontApi' -Namespace 'AMT' -PassThru;
282+
} catch {
283+
# Type may already exist from previous run
284+
$Script:FontApi = [AMT.FontApi];
285+
}
286+
287+
# Ensure cache folder exists
288+
if (-not (Test-Path -Path $Script:FontCacheFolder)) {
289+
$null = New-Item -Path $Script:FontCacheFolder -ItemType Directory -Force;
290+
Invoke-Debug "Created font cache folder: $Script:FontCacheFolder";
291+
}
292+
293+
# Normalize container URL (remove trailing slash)
294+
$StorageBlobUrl = $StorageBlobUrl.TrimEnd('/');
295+
296+
# Parse SAS token
297+
[String]$Local:SasQueryString = ConvertTo-SasQueryString -SasToken:$StorageBlobSasToken;
298+
299+
# List all font blobs
300+
[String[]]$Local:FontBlobs = Get-BlobList -ContainerUrl:$StorageBlobUrl -SasQueryString:$Local:SasQueryString;
301+
302+
if ($Local:FontBlobs.Count -eq 0) {
303+
Invoke-Info 'No font files found in container.';
304+
return;
305+
}
306+
307+
[Int32]$Local:InstalledCount = 0;
308+
[Int32]$Local:SkippedCount = 0;
309+
310+
foreach ($Local:BlobName in $Local:FontBlobs) {
311+
[String]$Local:FileName = [System.IO.Path]::GetFileName($Local:BlobName);
312+
313+
# Check if already installed
314+
if (Test-FontInstalled -FontFileName:$Local:FileName) {
315+
Invoke-Debug "Font $Local:FileName is already installed, skipping.";
316+
$Local:SkippedCount++;
317+
continue;
318+
}
319+
320+
# Get font file (from cache or download)
321+
[System.IO.FileInfo]$Local:FontFile = Get-FontFile -BlobName:$Local:BlobName -ContainerUrl:$StorageBlobUrl -SasQueryString:$Local:SasQueryString;
322+
323+
# Install the font
324+
Install-Font -FontFile:$Local:FontFile;
325+
$Local:InstalledCount++;
326+
}
327+
328+
# Broadcast font change if any fonts were installed
329+
if ($Local:InstalledCount -gt 0) {
330+
Send-FontChangeNotification;
331+
}
332+
333+
Invoke-Info "Font installation complete. Installed: $Local:InstalledCount, Skipped (already installed): $Local:SkippedCount";
334+
};

0 commit comments

Comments
 (0)