bridgerator 1.0.7 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/README.md +78 -15
  2. package/assets/bridgerator.ico +0 -0
  3. package/assets/icon-16.png +0 -0
  4. package/assets/icon-20.png +0 -0
  5. package/assets/icon-24.png +0 -0
  6. package/assets/icon-256.png +0 -0
  7. package/assets/icon-32.png +0 -0
  8. package/assets/icon-48.png +0 -0
  9. package/assets/icon-64.png +0 -0
  10. package/assets/logo.svg +1 -0
  11. package/install.ps1 +465 -43
  12. package/npm-shrinkwrap.json +8621 -0
  13. package/package.json +55 -49
  14. package/recipes/backup-folder.json +31 -0
  15. package/recipes/clean-downloads.json +48 -0
  16. package/recipes/disk-report.json +66 -0
  17. package/recipes/sort-folder.json +49 -0
  18. package/recipes/update-apps.json +25 -0
  19. package/src/ask-lib.ps1 +230 -0
  20. package/src/ask.mjs +51 -0
  21. package/src/ask.ps1 +17 -0
  22. package/src/automations.mjs +673 -0
  23. package/src/batch.mjs +221 -0
  24. package/src/bridge.mjs +743 -29
  25. package/src/cli.mjs +561 -53
  26. package/src/confirm.mjs +418 -0
  27. package/src/dc-config-hooks.mjs +18 -8
  28. package/src/dc-isolate.mjs +5 -0
  29. package/src/delegate.mjs +555 -0
  30. package/src/docs-tool.mjs +494 -0
  31. package/src/doctor.mjs +200 -0
  32. package/src/events.mjs +21 -0
  33. package/src/extra-tools.mjs +122 -0
  34. package/src/guard.mjs +34 -0
  35. package/src/notice.mjs +86 -23
  36. package/src/pair.mjs +41 -6
  37. package/src/pc-check.mjs +112 -0
  38. package/src/pc-check.ps1 +103 -0
  39. package/src/pc-text.mjs +94 -0
  40. package/src/pdf-font.mjs +245 -0
  41. package/src/photos-tool.mjs +187 -0
  42. package/src/photos.ps1 +145 -0
  43. package/src/plan.mjs +90 -0
  44. package/src/policy.mjs +293 -109
  45. package/src/queue.mjs +194 -0
  46. package/src/recent.mjs +48 -0
  47. package/src/recipes.mjs +108 -0
  48. package/src/recycle.mjs +185 -0
  49. package/src/recycle.ps1 +110 -0
  50. package/src/screen-tool.mjs +185 -0
  51. package/src/screen.ps1 +285 -0
  52. package/src/search-tool.mjs +189 -0
  53. package/src/search.ps1 +129 -0
  54. package/src/settings-tool.mjs +186 -0
  55. package/src/setup-tool.mjs +129 -0
  56. package/src/software-tool.mjs +185 -0
  57. package/src/telemetry.mjs +86 -0
  58. package/src/tool-lib.mjs +177 -0
  59. package/src/tool-text.mjs +45 -0
  60. package/src/tray.mjs +97 -0
  61. package/src/tray.ps1 +359 -0
  62. package/src/undo.mjs +313 -0
  63. package/src/win.mjs +17 -0
  64. package/ui/app.css +388 -0
  65. package/ui/app.js +868 -0
  66. package/ui/i18n/en.json +598 -0
  67. package/ui/i18n/ru.json +598 -0
  68. package/ui/index.html +26 -0
  69. package/vendor/webview2/LICENSE.txt +27 -0
  70. package/vendor/webview2/Microsoft.Web.WebView2.Core.dll +0 -0
  71. package/vendor/webview2/Microsoft.Web.WebView2.WinForms.dll +0 -0
  72. package/vendor/webview2/NOTICE.txt +85 -0
  73. package/vendor/webview2/SHA256SUMS.txt +4 -0
  74. package/vendor/webview2/SOURCE.txt +1 -0
  75. package/vendor/webview2/arm64/WebView2Loader.dll +0 -0
  76. package/vendor/webview2/x64/WebView2Loader.dll +0 -0
@@ -0,0 +1,112 @@
1
+ // LOGIC: see critical-logic/bridge.md ("PC check"). One-command, read-only PC health: disks (space,
2
+ // drive health), event log errors of the last days (grouped), startup programs, devices with driver
3
+ // problems, Windows updates, load and memory. Facts come from pc-check.ps1 (CIM classes, Get-WinEvent
4
+ // by Level/Id, registry) — numbers and ids, any Windows language; this file turns them into findings.
5
+ // A part that could not be read is reported as not checked, never as "fine".
6
+ import { fileURLToPath } from 'node:url';
7
+ import { psJson, json, needAction, int } from './tool-lib.mjs';
8
+
9
+ const PS = fileURLToPath(new URL('./pc-check.ps1', import.meta.url));
10
+ const ALL = ['disks', 'events', 'startup', 'drivers', 'updates', 'load'];
11
+ const GB = 1024 ** 3;
12
+ // MSFT_PhysicalDisk numbers.
13
+ const HEALTH = { 0: 'healthy', 1: 'warning', 2: 'unhealthy', 5: 'unknown' };
14
+ const MEDIA = { 0: 'unspecified', 3: 'HDD', 4: 'SSD', 5: 'SCM' };
15
+ // Win32_PnPEntity ConfigManagerErrorCode → plain words.
16
+ const DEVICE = {
17
+ 1: 'not configured correctly', 3: 'driver may be damaged or memory is low', 10: 'cannot start', 12: 'not enough free resources',
18
+ 14: 'needs a restart', 18: 'drivers need reinstalling', 19: 'registry data is damaged', 21: 'being removed', 22: 'disabled', 24: 'not present or not working',
19
+ 28: 'no driver installed', 29: 'disabled in firmware', 31: 'not working (driver could not load)', 32: 'driver service disabled', 37: 'driver failed to start',
20
+ 39: 'driver missing or damaged', 43: 'stopped and reported a problem (code 43)', 45: 'not connected', 47: 'prepared for safe removal', 48: 'blocked (known problems)', 52: 'driver signature cannot be verified',
21
+ };
22
+
23
+ /** Facts → { findings: [{ level: problem|warning|info|not_checked, area, text }], ...facts trimmed }. */
24
+ export function analyze(f, now = Date.now()) {
25
+ // PowerShell unrolls a list returned from a scriptblock: none → {} and one → the item itself. Normalize both (pc-check.ps1 also returns ,@()).
26
+ for (const k of ['startup', 'drivers']) { const v = f[k]; if (k in f && !Array.isArray(v) && !v?.error) f = { ...f, [k]: v && Object.keys(v).length ? [v] : [] }; }
27
+ const out = [];
28
+ const add = (level, area, text) => out.push({ level, area, text });
29
+ const nc = (area, v) => { if (v && !Array.isArray(v) && v.error) { add('not_checked', area, `Could not read: ${v.error}`); return true; } return false; };
30
+ if (f.disks && !nc('disks', f.disks)) {
31
+ for (const v of f.disks.volumes || []) {
32
+ if (!v.size) continue;
33
+ const pct = (v.free / v.size) * 100;
34
+ const txt = `${v.drive} ${(v.free / GB).toFixed(1)} GB free of ${(v.size / GB).toFixed(0)} GB (${pct.toFixed(0)} %)`;
35
+ if (v.free < 2 * GB || pct < 5) add('problem', 'disks', `${txt} — almost full`);
36
+ else if (v.free < 10 * GB || pct < 10) add('warning', 'disks', `${txt} — getting full`);
37
+ else add('info', 'disks', txt);
38
+ }
39
+ if (nc('disks', f.disks.physical)) { /* reported */ } else for (const p of f.disks.physical || []) {
40
+ const h = HEALTH[p.health] || 'unknown';
41
+ add(h === 'healthy' ? 'info' : h === 'unknown' ? 'not_checked' : 'problem', 'disks', `Drive "${p.name}" (${MEDIA[p.media] || 'disk'}): health ${h}${h === 'unhealthy' || h === 'warning' ? ' — back up important files and plan a replacement' : ''}`);
42
+ }
43
+ }
44
+ if (f.events && !nc('events', f.events)) {
45
+ const e = f.events;
46
+ if (!e.total) add('info', 'events', `No errors in the System and Application logs in the last ${e.days} days`);
47
+ else {
48
+ add(e.critical ? 'warning' : 'info', 'events', `${e.total} errors${e.critical ? `, ${e.critical} critical` : ''} in the last ${e.days} days (the most frequent are listed in "events")`);
49
+ // Event 41 Kernel-Power = the PC was not shut down cleanly (power loss, freeze, forced off).
50
+ // Counted by pc-check.ps1 over all events (kernelPower); older facts: only the listed groups.
51
+ const kp = Number.isInteger(e.kernelPower) ? e.kernelPower : (e.groups || []).find((g) => g.id === 41 && /Kernel-Power/i.test(g.source))?.count || 0;
52
+ if (kp) add('warning', 'events', `The PC turned off unexpectedly ${kp} time(s) (power loss, freeze or holding the power button)`);
53
+ }
54
+ }
55
+ if (f.startup && !nc('startup', f.startup)) {
56
+ const on = f.startup.filter((s) => s.enabled !== false);
57
+ add(on.length > 15 ? 'warning' : 'info', 'startup', `${on.length} programs start with Windows${f.startup.length > on.length ? ` (${f.startup.length - on.length} more turned off)` : ''}${on.length > 15 ? ' — many; turning some off speeds up the start' : ''}`);
58
+ }
59
+ if (f.drivers && !nc('drivers', f.drivers)) {
60
+ const off = f.drivers.filter((d) => d.code === 22), bad = f.drivers.filter((d) => d.code !== 22);
61
+ if (!bad.length) add('info', 'drivers', 'All devices work');
62
+ for (const d of bad) add('problem', 'drivers', `${d.name || d.id}: ${DEVICE[d.code] || `problem code ${d.code}`}`);
63
+ // Code 22 = turned off on purpose (Device Manager → Disable): not a problem.
64
+ if (off.length) add('info', 'drivers', `${off.length} device(s) turned off in Device Manager: ${[...new Set(off.map((d) => d.name || d.id))].slice(0, 5).join(', ')}`);
65
+ }
66
+ if (f.updates && !nc('updates', f.updates)) {
67
+ const u = f.updates;
68
+ const last = (u.recent || [])[0];
69
+ if (last) {
70
+ const age = Math.round((now - Date.parse(last.installed)) / 86400000);
71
+ add(age > 60 ? 'warning' : 'info', 'updates', `Last update installed ${last.installed} (${last.id})${age > 60 ? ` — ${age} days ago, check Windows Update` : ''}`);
72
+ } else add('not_checked', 'updates', 'No installed updates listed');
73
+ if (u.rebootPending) add('warning', 'updates', 'A restart is needed to finish updates');
74
+ if (Array.isArray(u.pending)) add(u.pending.length ? 'warning' : 'info', 'updates', u.pending.length ? `${u.pending.length} updates waiting to be installed` : 'No updates waiting');
75
+ else nc('updates', u.pending);
76
+ }
77
+ if (f.load && !nc('load', f.load)) {
78
+ const l = f.load;
79
+ const used = l.memTotalMb ? Math.round(((l.memTotalMb - l.memFreeMb) / l.memTotalMb) * 100) : null;
80
+ if (used !== null) add(used > 90 ? 'warning' : 'info', 'load', `Memory ${used} % used (${Math.round(l.memFreeMb / 1024 * 10) / 10} GB free of ${Math.round(l.memTotalMb / 1024)} GB)`);
81
+ if (Number.isFinite(l.cpuLoad)) add(l.cpuLoad > 85 ? 'warning' : 'info', 'load', `Processor load ${Math.round(l.cpuLoad)} %`);
82
+ const up = Date.parse(l.bootedAt);
83
+ if (Number.isFinite(up)) { const d = Math.floor((now - up) / 86400000); add(d > 14 ? 'warning' : 'info', 'load', `Running ${d} day(s) since the last restart${d > 14 ? ' — a restart often helps' : ''}`); }
84
+ }
85
+ const order = { problem: 0, warning: 1, not_checked: 2, info: 3 };
86
+ out.sort((a, b) => order[a.level] - order[b.level]);
87
+ return out;
88
+ }
89
+
90
+ export const defs = [{
91
+ name: 'pc_health',
92
+ title: 'PC health check',
93
+ description: 'Read-only health check of this PC. summary: everything at once (disks, event log errors, startup programs, devices with driver problems, Windows updates, memory and load); or one part: disks, events (errors of the last `days`), startup, drivers, updates (pending: true also asks Windows Update for waiting updates — slow), load. Returns findings in plain words plus the details. Changes nothing.',
94
+ inputSchema: {
95
+ type: 'object', additionalProperties: false, required: ['action'],
96
+ properties: {
97
+ action: { type: 'string', enum: ['summary', ...ALL] },
98
+ days: { type: 'integer', minimum: 1, maximum: 30, description: 'events: how many days back (default 3)' },
99
+ pending: { type: 'boolean', description: 'updates: also list updates waiting to be installed (takes up to a few minutes)' },
100
+ },
101
+ },
102
+ annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false },
103
+ }];
104
+
105
+ export async function handle(name, args, ctx) {
106
+ const action = needAction(args, defs[0].inputSchema.properties.action.enum);
107
+ const days = int(args, 'days', { min: 1, max: 30, def: 3 });
108
+ const parts = action === 'summary' ? ALL : [action];
109
+ const pending = args.pending === true && parts.includes('updates');
110
+ const facts = await psJson(ctx, { files: [PS], env: { BRX_PARTS: parts.join(','), BRX_DAYS: String(days), BRX_PENDING: pending ? '1' : '' }, timeout: pending ? 300000 : 90000 });
111
+ return json({ action, findings: analyze(facts), details: facts });
112
+ }
@@ -0,0 +1,103 @@
1
+ # Bridgerator PC check (read-only). Input: BRX_PARTS (comma list: disks,events,startup,drivers,updates,load),
2
+ # BRX_DAYS (event log days), BRX_PENDING (1 = ask Windows Update for pending updates, slow).
3
+ # Output: ONE JSON line. Every part is caught on its own: a failed part is { error } (NOT CHECKED), not a failed run.
4
+ # Values are read as numbers / ids / registry data, never as localized label text.
5
+ $parts = @(($env:BRX_PARTS -split ',') | Where-Object { $_ })
6
+ $days = 3; [int]::TryParse([string]$env:BRX_DAYS, [ref]$days) | Out-Null
7
+ if ($days -lt 1 -or $days -gt 30) { $days = 3 }
8
+ $out = [ordered]@{}
9
+ function Cut([string]$s, [int]$n) { if ($null -eq $s) { return '' }; $s = ($s -replace '\s+', ' ').Trim(); if ($s.Length -gt $n) { return $s.Substring(0, $n) + '...' }; return $s }
10
+ function Part([string]$name, [scriptblock]$body) {
11
+ if ($parts -notcontains $name) { return }
12
+ try { $out[$name] = & $body } catch { $out[$name] = @{ error = (Cut $_.Exception.Message 200) } }
13
+ }
14
+
15
+ Part 'disks' {
16
+ $vol = @(Get-CimInstance Win32_LogicalDisk -Filter 'DriveType=3' -ErrorAction Stop | ForEach-Object {
17
+ @{ drive = [string]$_.DeviceID; label = [string]$_.VolumeName; fs = [string]$_.FileSystem; size = [int64]$_.Size; free = [int64]$_.FreeSpace }
18
+ })
19
+ $phys = $null
20
+ try {
21
+ $phys = @(Get-CimInstance -Namespace 'root/Microsoft/Windows/Storage' -ClassName MSFT_PhysicalDisk -ErrorAction Stop | ForEach-Object {
22
+ @{ name = [string]$_.FriendlyName; media = [int]$_.MediaType; bus = [int]$_.BusType; health = [int]$_.HealthStatus; status = @($_.OperationalStatus | ForEach-Object { [int]$_ }); size = [int64]$_.Size }
23
+ })
24
+ } catch { $phys = @{ error = (Cut $_.Exception.Message 160) } }
25
+ @{ volumes = $vol; physical = $phys }
26
+ }
27
+
28
+ Part 'events' {
29
+ $since = (Get-Date).AddDays(-$days)
30
+ $ev = @()
31
+ foreach ($log in 'System', 'Application') {
32
+ try { $ev += @(Get-WinEvent -FilterHashtable @{ LogName = $log; Level = 1, 2; StartTime = $since } -MaxEvents 3000 -ErrorAction Stop) }
33
+ catch { if ($_.FullyQualifiedErrorId -notlike 'NoMatchingEventsFound*') { throw } }
34
+ }
35
+ $groups = @($ev | Group-Object { "$($_.LogName)|$($_.ProviderName)|$($_.Id)" } | Sort-Object Count -Descending | Select-Object -First 15 | ForEach-Object {
36
+ $last = $_.Group | Sort-Object TimeCreated -Descending | Select-Object -First 1
37
+ $msg = ''; try { $msg = $last.Message } catch {}
38
+ @{ log = [string]$last.LogName; source = [string]$last.ProviderName; id = [int]$last.Id; level = [int]$last.Level; count = [int]$_.Count; last = $last.TimeCreated.ToString('o'); message = (Cut $msg 220) }
39
+ })
40
+ # Kernel-Power 41 (turned off unexpectedly) is counted over ALL events, not only the 15 most frequent groups.
41
+ $kp = @($ev | Where-Object { $_.Id -eq 41 -and [string]$_.ProviderName -eq 'Microsoft-Windows-Kernel-Power' }).Count
42
+ @{ days = $days; total = $ev.Count; critical = @($ev | Where-Object { $_.Level -eq 1 }).Count; kernelPower = $kp; groups = $groups }
43
+ }
44
+
45
+ Part 'startup' {
46
+ $approved = @{}
47
+ foreach ($k in 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Explorer\StartupApproved\Run', 'HKLM:\Software\Microsoft\Windows\CurrentVersion\Explorer\StartupApproved\Run', 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Explorer\StartupApproved\StartupFolder') {
48
+ try { $p = Get-ItemProperty $k -ErrorAction Stop; foreach ($n in $p.PSObject.Properties) { if ($n.Value -is [byte[]] -and $n.Value.Length -gt 0) { $approved[$n.Name] = ($n.Value[0] % 2 -eq 0) } } } catch {}
49
+ }
50
+ ,@(Get-CimInstance Win32_StartupCommand -ErrorAction Stop | ForEach-Object {
51
+ $name = [string]$_.Name; $en = $true
52
+ foreach ($key in @($name, [string]$_.Caption, ([string]$_.Command -split '\\')[-1])) { if ($approved.ContainsKey($key)) { $en = $approved[$key]; break } }
53
+ @{ name = $name; command = (Cut ([string]$_.Command) 200); location = [string]$_.Location; user = [string]$_.User; enabled = $en }
54
+ })
55
+ }
56
+
57
+ Part 'drivers' {
58
+ ,@(Get-CimInstance Win32_PnPEntity -Filter 'ConfigManagerErrorCode<>0' -ErrorAction Stop | Select-Object -First 50 | ForEach-Object {
59
+ @{ name = [string]$_.Name; class = [string]$_.PNPClass; code = [int]$_.ConfigManagerErrorCode; id = (Cut ([string]$_.PNPDeviceID) 120) }
60
+ })
61
+ }
62
+
63
+ # Win32_QuickFixEngineering.InstalledOn is a string in the PC's own date format (15.10.2026, 10/15/2026)
64
+ # or a hex FILETIME on some updates: M/d/yyyy (slashes) is read invariant first, other forms with the
65
+ # current culture first; unreadable = skipped.
66
+ function To-Date($v) {
67
+ $s = ([string]$v).Trim()
68
+ if (-not $s) { return $null }
69
+ if ($s -match '^[0-9A-Fa-f]{16}$') { try { return [DateTime]::FromFileTimeUtc([Convert]::ToInt64($s, 16)) } catch { return $null } }
70
+ $d = [datetime]::MinValue
71
+ $order = @([Globalization.CultureInfo]::CurrentCulture, [Globalization.CultureInfo]::InvariantCulture)
72
+ if ($s.Contains('/')) { [array]::Reverse($order) }
73
+ foreach ($c in $order) {
74
+ if ([datetime]::TryParse($s, $c, [Globalization.DateTimeStyles]::None, [ref]$d)) { return $d }
75
+ }
76
+ return $null
77
+ }
78
+ Part 'updates' {
79
+ $hot = @(Get-CimInstance Win32_QuickFixEngineering -ErrorAction Stop | ForEach-Object { $d = To-Date $_.InstalledOn; if ($d) { @{ id = [string]$_.HotFixID; on = $d } } } | Sort-Object { $_.on } -Descending | Select-Object -First 5 | ForEach-Object {
80
+ @{ id = $_.id; installed = $_.on.ToString('yyyy-MM-dd', [Globalization.CultureInfo]::InvariantCulture) }
81
+ })
82
+ $reboot = (Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Auto Update\RebootRequired') -or (Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing\RebootPending')
83
+ $pending = $null
84
+ if ($env:BRX_PENDING -eq '1') {
85
+ try {
86
+ $s = (New-Object -ComObject Microsoft.Update.Session).CreateUpdateSearcher()
87
+ $r = $s.Search('IsInstalled=0 and IsHidden=0 and Type=''Software''')
88
+ $pending = @($r.Updates | Select-Object -First 30 | ForEach-Object { @{ title = (Cut ([string]$_.Title) 160); kb = (@($_.KBArticleIDs) -join ','); mb = [math]::Round($_.MaxDownloadSize / 1MB) } })
89
+ } catch { $pending = @{ error = (Cut $_.Exception.Message 160) } }
90
+ }
91
+ @{ recent = $hot; rebootPending = [bool]$reboot; pending = $pending }
92
+ }
93
+
94
+ Part 'load' {
95
+ $os = Get-CimInstance Win32_OperatingSystem -ErrorAction Stop
96
+ $cpu = @(Get-CimInstance Win32_Processor -ErrorAction Stop)
97
+ $load = ($cpu | Measure-Object -Property LoadPercentage -Average).Average
98
+ $top = @(Get-Process -ErrorAction SilentlyContinue | Sort-Object WorkingSet64 -Descending | Select-Object -First 6 | ForEach-Object { @{ name = [string]$_.ProcessName; mb = [math]::Round($_.WorkingSet64 / 1MB); cpuSec = [math]::Round([double]$_.CPU) } })
99
+ $topCpu = @(Get-Process -ErrorAction SilentlyContinue | Where-Object { $_.CPU } | Sort-Object CPU -Descending | Select-Object -First 6 | ForEach-Object { @{ name = [string]$_.ProcessName; mb = [math]::Round($_.WorkingSet64 / 1MB); cpuSec = [math]::Round([double]$_.CPU) } })
100
+ @{ os = [string]$os.Caption; build = [string]$os.BuildNumber; bootedAt = $os.LastBootUpTime.ToString('o'); memTotalMb = [math]::Round($os.TotalVisibleMemorySize / 1KB); memFreeMb = [math]::Round($os.FreePhysicalMemory / 1KB); cpuLoad = $load; cpu = [string]$cpu[0].Name; cores = [int](($cpu | Measure-Object -Property NumberOfLogicalProcessors -Sum).Sum); topMemory = $top; topCpu = $topCpu }
101
+ }
102
+
103
+ $out | ConvertTo-Json -Compress -Depth 6
@@ -0,0 +1,94 @@
1
+ // The questions on the PC (toast / fallback window) in the PC's language (config.json `language`,
2
+ // en | ru — the same choice as the window). The bridge writes every question in English; this file
3
+ // turns the fixed texts (headers, buttons, fixed sentences, the "auto-deny in 2 min" line) into
4
+ // Russian just before they are shown. Commands, paths and names stay as they are.
5
+ // LOGIC: critical-logic/bridge.md ("Questions on the PC: language").
6
+
7
+ const RU = {
8
+ // headers (confirm.mjs HEAD, bridge.mjs, automations.mjs)
9
+ 'ChatGPT wants to delete files': 'ChatGPT хочет удалить файлы',
10
+ 'ChatGPT wants to run a system command': 'ChatGPT хочет выполнить системную команду',
11
+ 'ChatGPT wants to change a file': 'ChatGPT хочет изменить файл',
12
+ 'ChatGPT wants to move or rename': 'ChatGPT хочет переместить или переименовать',
13
+ 'ChatGPT wants to run a command': 'ChatGPT хочет выполнить команду',
14
+ 'ChatGPT wants to install or update a program': 'ChatGPT хочет установить или обновить программу',
15
+ 'ChatGPT wants to schedule a script': 'ChatGPT хочет поставить сценарий на расписание',
16
+ 'ChatGPT wants to change a scheduled script': 'ChatGPT хочет изменить сценарий на расписании',
17
+ 'ChatGPT wants to do something your rules ask about': 'ChatGPT хочет сделать то, о чём просят спрашивать ваши правила',
18
+ 'ChatGPT asks to stop confirming actions on this PC. Allow?': 'ChatGPT просит больше не спрашивать подтверждений на этом ПК. Разрешить?',
19
+ 'Bridgerator settings file changed': 'Файл настроек Bridgerator изменён',
20
+ 'ChatGPT suggests Bridgerator settings': 'ChatGPT предлагает настройки Bridgerator',
21
+ 'ChatGPT wants to change Bridgerator settings': 'ChatGPT хочет изменить настройки Bridgerator',
22
+ 'ChatGPT scheduled a script on this PC': 'ChatGPT поставил сценарий на расписание на этом ПК',
23
+ 'Test notification': 'Пробное уведомление',
24
+ // fixed sentences
25
+ 'After that, deletions and system commands run without asking you. Turn it back on any time: bridgerator.cmd confirm on': 'После этого удаление и системные команды выполняются без вопроса. Включить снова можно в любой момент: bridgerator.cmd confirm on',
26
+ 'Too many changes for a notification — review them in the Bridgerator window.': 'Слишком много изменений для уведомления — посмотрите их в окне Bridgerator.',
27
+ 'Every task in the Bridgerator queue will be removed.': 'Все задачи в очереди Bridgerator будут удалены.',
28
+ 'Bridgerator paused itself. Continue? No answer = it stays paused.': 'Bridgerator поставил себя на паузу. Продолжить? Нет ответа = пауза остаётся.',
29
+ 'This is how Bridgerator asks before risky actions. Nothing will run.': 'Так Bridgerator спрашивает перед рискованными действиями. Ничего не будет выполнено.',
30
+ // buttons
31
+ Allow: 'Разрешить', 'Allow 10 min': 'Разрешить на 10 мин', 'Allow always': 'Разрешать всегда', Deny: 'Запретить',
32
+ Apply: 'Применить', Keep: 'Оставить', Cancel: 'Отмена', Clear: 'Очистить', Continue: 'Продолжить', 'Keep paused': 'Оставить паузу',
33
+ Delete: 'Удалить', Show: 'Показать', 'Turn off': 'Выключить', 'Allow all': 'Разрешить всё', 'Review…': 'Просмотреть…',
34
+ };
35
+ // "auto-deny in 2 min": the word for what happens without an answer.
36
+ const AUTO_RU = { 'auto-deny': 'авто-запрет', 'auto-cancel': 'авто-отмена', 'auto-keep': 'авто-сохранение', closes: 'закроется' };
37
+ // What a changed confirm.ini would turn off (confirm.mjs weakerBy / LABEL).
38
+ const OFF_RU = {
39
+ confirmations: 'подтверждения', 'your own patterns': 'ваши шаблоны',
40
+ 'confirmations for deletions': 'подтверждения удаления', 'confirmations for system commands': 'подтверждения системных команд',
41
+ 'confirmations for file changes': 'подтверждения изменения файлов', 'confirmations for moves and renames': 'подтверждения перемещений',
42
+ 'confirmations for commands': 'подтверждения команд', 'confirmations for installing programs': 'подтверждения установки программ',
43
+ 'confirmations for scheduled scripts': 'подтверждения сценариев на расписании',
44
+ };
45
+ const PLAN_VERB_RU = { move: 'переместить', write: 'записать', edit: 'изменить', create: 'создать', run: 'выполнить', do: 'выполнить' };
46
+ /** Russian plural: forms = [1 файл, 2 файла, 5 файлов] → the form for n. */
47
+ export function ruPlural(n, [one, few, many]) {
48
+ const a = Math.abs(Math.trunc(n)) % 100, b = a % 10;
49
+ return a >= 11 && a <= 14 ? many : b === 1 ? one : b >= 2 && b <= 4 ? few : many;
50
+ }
51
+ // Plan card nouns (batch.mjs): after the verb ("переместить 1 файл", "выполнить 2 команды") and in the
52
+ // mixed list ("214 перемещений, 1 команда"). English singular/plural both map to one entry.
53
+ const PLAN_OBJ_RU = { file: ['файл', 'файла', 'файлов'], folder: ['папку', 'папки', 'папок'], command: ['команду', 'команды', 'команд'], action: ['действие', 'действия', 'действий'] };
54
+ const PLAN_LIST_RU = { move: ['перемещение', 'перемещения', 'перемещений'], write: ['запись', 'записи', 'записей'], edit: ['правка', 'правки', 'правок'], 'new folder': ['новая папка', 'новые папки', 'новых папок'], command: ['команда', 'команды', 'команд'] };
55
+ const planCount = (n, word, forms) => { const f = forms[word.replace(/s$/, '')]; return f ? `${n} ${ruPlural(Number(n), f)}` : null; };
56
+
57
+ /** One English text → Russian (unknown text stays English). */
58
+ export function ruText(t) {
59
+ if (typeof t !== 'string' || !t) return t;
60
+ if (Object.hasOwn(RU, t)) return RU[t];
61
+ let m;
62
+ if ((m = /^Turn off (.+)\?$/.exec(t))) return `Выключить: ${m[1].split(', ').map((x) => OFF_RU[x] || x).join(', ')}?`;
63
+ if ((m = /^Apply: (.+)\?$/s.exec(t))) return `Применить: ${m[1]}?`;
64
+ if ((m = /^ChatGPT wants to clear (\d+) tasks?$/.exec(t))) return `ChatGPT хочет удалить задачи из очереди: ${m[1]}`;
65
+ if ((m = /^ChatGPT deleted or moved (\d+) files in a minute$/.exec(t))) return `ChatGPT удалил или переместил файлов за минуту: ${m[1]}`;
66
+ if ((m = /^ChatGPT wants to delete (\d+) items? for good$/.exec(t))) return `ChatGPT хочет удалить безвозвратно: ${m[1]}`;
67
+ if ((m = /^No Recycle Bin on that drive: (.*)$/s.exec(t))) return `На этом диске нет Корзины: ${m[1]}`;
68
+ // Plan card (batch.mjs headline): "ChatGPT wants to move 214 files" / "… to do 220 actions (214 moves, 6 commands)".
69
+ if ((m = /^ChatGPT wants to (move|write|edit|create|run|do) (\d+) ([a-z ]+?)( \((.+)\))?$/.exec(t))) {
70
+ const head = planCount(m[2], m[3], PLAN_OBJ_RU);
71
+ const list = m[5] ? m[5].split(', ').map((x) => { const p = /^(\d+) ([a-z ]+)$/.exec(x); return p && planCount(p[1], p[2], PLAN_LIST_RU); }) : [];
72
+ if (head && !list.includes(null)) return `ChatGPT хочет ${PLAN_VERB_RU[m[1]]} ${head}${list.length ? ` (${list.join(', ')})` : ''}`;
73
+ }
74
+ return t;
75
+ }
76
+
77
+ /** The attribution line under a question: "PC-NAME · auto-deny in 2 min". */
78
+ export function attrLine(host, auto, secs, lang) {
79
+ const ru = lang === 'ru';
80
+ const when = secs >= 120 && secs % 60 === 0 ? `${secs / 60} ${ru ? 'мин' : 'min'}` : `${secs} ${ru ? 'с' : 's'}`;
81
+ return `${host} · ${ru ? AUTO_RU[auto] || auto : auto} ${ru ? 'через' : 'in'} ${when}`;
82
+ }
83
+
84
+ /** A question as it is shown on a PC with language `lang` (head, text, button labels, auto word). */
85
+ export function localizeAsk(q, lang) {
86
+ if (lang !== 'ru') return q;
87
+ return {
88
+ ...q,
89
+ head: ruText(q.head),
90
+ text: ruText(q.text),
91
+ buttons: (q.buttons || []).map(([k, label, kind]) => [k, ruText(label), kind]),
92
+ auto: q.auto ? AUTO_RU[q.auto] || q.auto : q.auto,
93
+ };
94
+ }
@@ -0,0 +1,245 @@
1
+ // LOGIC: see critical-logic/bridge.md ("Extra tools" → create_document). A tiny TrueType reader for
2
+ // PDF output with the fonts every Windows PC has (Segoe UI / Arial / Tahoma): metrics for layout, a
3
+ // subset with only the glyphs used (glyph ids kept, other outlines dropped → a small file), and an
4
+ // object with the fields pdf-lib's custom-font embedder reads (it normally takes @pdf-lib/fontkit,
5
+ // which is not installed — this replaces it for plain left-to-right text, no shaping / kerning).
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+
9
+ export class FontError extends Error { constructor(m) { super(m); this.code = 'FONT'; } }
10
+
11
+ const tag = (b, o) => String.fromCharCode(b[o], b[o + 1], b[o + 2], b[o + 3]);
12
+
13
+ /** Parses a .ttf (glyf outlines; CFF / collections are refused). */
14
+ export function parseTtf(bytes) {
15
+ const b = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength);
16
+ if (b.length < 12) throw new FontError('not a font file');
17
+ const ver = b.readUInt32BE(0);
18
+ if (ver !== 0x00010000 && tag(b, 0) !== 'true') throw new FontError('only TrueType (.ttf) fonts are supported');
19
+ const n = b.readUInt16BE(4), tables = {};
20
+ for (let i = 0; i < n; i++) {
21
+ const o = 12 + i * 16;
22
+ const off = b.readUInt32BE(o + 8), len = b.readUInt32BE(o + 12);
23
+ if (off + len > b.length) throw new FontError('broken font file');
24
+ tables[tag(b, o)] = b.subarray(off, off + len);
25
+ }
26
+ for (const t of ['head', 'hhea', 'maxp', 'hmtx', 'loca', 'glyf', 'cmap']) if (!tables[t]) throw new FontError(`font has no ${t} table`);
27
+ const head = tables.head, hhea = tables.hhea;
28
+ const unitsPerEm = head.readUInt16BE(18);
29
+ const bbox = { minX: head.readInt16BE(36), minY: head.readInt16BE(38), maxX: head.readInt16BE(40), maxY: head.readInt16BE(42) };
30
+ const macStyle = head.readUInt16BE(44);
31
+ const locFmt = head.readInt16BE(50);
32
+ const numGlyphs = tables.maxp.readUInt16BE(4);
33
+ const nHM = hhea.readUInt16BE(34);
34
+ const hmtx = tables.hmtx;
35
+ const advance = (g) => hmtx.readUInt16BE(4 * Math.min(g, nHM - 1));
36
+ const cmap = readCmap(tables.cmap);
37
+ const post = tables.post;
38
+ const os2 = tables['OS/2'];
39
+ const os2v = os2 ? os2.readUInt16BE(0) : 0;
40
+ const loca = (g) => (locFmt ? tables.loca.readUInt32BE(4 * g) : tables.loca.readUInt16BE(2 * g) * 2);
41
+ return {
42
+ tables, unitsPerEm, bbox, numGlyphs, cmap, advance, loca,
43
+ ascent: hhea.readInt16BE(4), descent: hhea.readInt16BE(6),
44
+ italicAngle: post ? post.readInt32BE(4) / 65536 : 0,
45
+ isFixedPitch: post ? post.readUInt32BE(12) !== 0 : false,
46
+ familyClass: os2 && os2.length > 32 ? os2.readInt16BE(30) : 0,
47
+ xHeight: os2 && os2v >= 2 && os2.length > 90 ? os2.readInt16BE(86) : 0,
48
+ capHeight: os2 && os2v >= 2 && os2.length > 90 ? os2.readInt16BE(88) : 0,
49
+ italic: (macStyle & 2) !== 0, bold: (macStyle & 1) !== 0,
50
+ postscriptName: psName(tables.name),
51
+ };
52
+ }
53
+
54
+ function readCmap(t) {
55
+ const n = t.readUInt16BE(2);
56
+ const subs = [];
57
+ for (let i = 0; i < n; i++) {
58
+ const o = 4 + i * 8;
59
+ subs.push({ pid: t.readUInt16BE(o), eid: t.readUInt16BE(o + 2), off: t.readUInt32BE(o + 4) });
60
+ }
61
+ const pick = (pid, eid, fmt) => subs.find((s) => s.pid === pid && s.eid === eid && s.off + 2 <= t.length && t.readUInt16BE(s.off) === fmt);
62
+ const s12 = pick(3, 10, 12) || pick(0, 4, 12) || pick(0, 6, 12);
63
+ const map = new Map();
64
+ if (s12) {
65
+ const o = s12.off, groups = t.readUInt32BE(o + 12);
66
+ for (let i = 0; i < groups && o + 16 + i * 12 + 12 <= t.length; i++) {
67
+ const g = o + 16 + i * 12;
68
+ const start = t.readUInt32BE(g), end = Math.min(t.readUInt32BE(g + 4), start + 0x10000), gid = t.readUInt32BE(g + 8);
69
+ for (let c = start; c <= end; c++) if (gid + c - start) map.set(c, gid + c - start);
70
+ }
71
+ return map;
72
+ }
73
+ const s4 = pick(3, 1, 4) || pick(0, 3, 4) || pick(0, 4, 4) || pick(0, 1, 4);
74
+ if (!s4) throw new FontError('font has no Unicode character map');
75
+ const o = s4.off, segX2 = t.readUInt16BE(o + 6), segs = segX2 / 2;
76
+ const ends = o + 14, starts = ends + segX2 + 2, deltas = starts + segX2, ranges = deltas + segX2;
77
+ for (let i = 0; i < segs; i++) {
78
+ const end = t.readUInt16BE(ends + 2 * i), start = t.readUInt16BE(starts + 2 * i);
79
+ const delta = t.readInt16BE(deltas + 2 * i), ro = t.readUInt16BE(ranges + 2 * i);
80
+ for (let c = start; c <= end && c !== 0xffff; c++) {
81
+ let g;
82
+ if (!ro) g = (c + delta) & 0xffff;
83
+ else {
84
+ const at = ranges + 2 * i + ro + 2 * (c - start);
85
+ if (at + 2 > t.length) continue;
86
+ g = t.readUInt16BE(at);
87
+ if (g) g = (g + delta) & 0xffff;
88
+ }
89
+ if (g) map.set(c, g);
90
+ }
91
+ }
92
+ return map;
93
+ }
94
+
95
+ function psName(t) {
96
+ if (!t) return 'Font';
97
+ const count = t.readUInt16BE(2), strOff = t.readUInt16BE(4);
98
+ for (let i = 0; i < count; i++) {
99
+ const o = 6 + i * 12;
100
+ const pid = t.readUInt16BE(o), nameId = t.readUInt16BE(o + 6), len = t.readUInt16BE(o + 8), off = t.readUInt16BE(o + 10);
101
+ if (nameId !== 6) continue;
102
+ const raw = t.subarray(strOff + off, strOff + off + len);
103
+ let s = '';
104
+ if (pid === 3 || pid === 0) for (let k = 0; k + 1 < raw.length; k += 2) s += String.fromCharCode(raw.readUInt16BE(k));
105
+ else s = raw.toString('latin1');
106
+ s = s.replace(/[^A-Za-z0-9-]/g, '');
107
+ if (s) return s.slice(0, 60);
108
+ }
109
+ return 'Font';
110
+ }
111
+
112
+ const pad4 = (buf) => (buf.length % 4 ? Buffer.concat([buf, Buffer.alloc(4 - (buf.length % 4))]) : buf);
113
+ function checksum(buf) {
114
+ const p = pad4(buf);
115
+ let s = 0;
116
+ for (let i = 0; i < p.length; i += 4) s = (s + p.readUInt32BE(i)) >>> 0;
117
+ return s;
118
+ }
119
+
120
+ /**
121
+ * A new .ttf with the same glyph ids but only the outlines of `codePoints` (plus composite parts
122
+ * and .notdef), a cmap of only those characters, post format 3 (no glyph names).
123
+ */
124
+ export function subsetTtf(font, codePoints) {
125
+ const { tables, numGlyphs, loca } = font;
126
+ const glyf = tables.glyf;
127
+ const keep = new Set([0]);
128
+ const map = [];
129
+ for (const c of codePoints) { const g = font.cmap.get(c); if (g) { map.push([c, g]); keep.add(g); } }
130
+ // Composite glyphs reference other glyphs: keep those too.
131
+ const stack = [...keep];
132
+ while (stack.length) {
133
+ const g = stack.pop();
134
+ const s = loca(g), e = loca(g + 1);
135
+ if (e - s < 10 || glyf.readInt16BE(s) >= 0) continue;
136
+ let o = s + 10;
137
+ for (let guard = 0; guard < 64 && o + 4 <= e; guard++) {
138
+ const flags = glyf.readUInt16BE(o), comp = glyf.readUInt16BE(o + 2);
139
+ if (comp < numGlyphs && !keep.has(comp)) { keep.add(comp); stack.push(comp); }
140
+ o += 4 + (flags & 1 ? 4 : 2) + (flags & 8 ? 2 : flags & 0x40 ? 4 : flags & 0x80 ? 8 : 0);
141
+ if (!(flags & 0x20)) break;
142
+ }
143
+ }
144
+ const parts = [], newLoca = Buffer.alloc(4 * (numGlyphs + 1));
145
+ let at = 0;
146
+ for (let g = 0; g < numGlyphs; g++) {
147
+ newLoca.writeUInt32BE(at, 4 * g);
148
+ if (!keep.has(g)) continue;
149
+ const s = loca(g), e = loca(g + 1);
150
+ if (e > s) { const d = pad4(Buffer.from(glyf.subarray(s, e))); parts.push(d); at += d.length; }
151
+ }
152
+ newLoca.writeUInt32BE(at, 4 * numGlyphs);
153
+ // cmap: one format-12 subtable (3,10) with the used characters, as consecutive groups.
154
+ map.sort((a, b) => a[0] - b[0]);
155
+ const groups = [];
156
+ for (const [c, g] of map) {
157
+ const last = groups[groups.length - 1];
158
+ if (last && c === last[1] + 1 && g === last[2] + (c - last[0])) last[1] = c; else groups.push([c, c, g]);
159
+ }
160
+ const cmap = Buffer.alloc(12 + 16 + groups.length * 12);
161
+ cmap.writeUInt16BE(0, 0); cmap.writeUInt16BE(1, 2);
162
+ cmap.writeUInt16BE(3, 4); cmap.writeUInt16BE(10, 6); cmap.writeUInt32BE(12, 8);
163
+ cmap.writeUInt16BE(12, 12); cmap.writeUInt32BE(16 + groups.length * 12, 16); cmap.writeUInt32BE(groups.length, 24);
164
+ groups.forEach(([s, e, g], i) => { cmap.writeUInt32BE(s, 28 + i * 12); cmap.writeUInt32BE(e, 32 + i * 12); cmap.writeUInt32BE(g, 36 + i * 12); });
165
+ const head = Buffer.from(tables.head);
166
+ head.writeUInt32BE(0, 8); // checkSumAdjustment, set below
167
+ head.writeInt16BE(1, 50); // long loca
168
+ const post = Buffer.alloc(32);
169
+ if (tables.post) tables.post.copy(post, 0, 0, Math.min(32, tables.post.length));
170
+ post.writeUInt32BE(0x00030000, 0);
171
+ const out = { head, hhea: tables.hhea, maxp: tables.maxp, hmtx: tables.hmtx, loca: newLoca, glyf: Buffer.concat(parts), cmap, post };
172
+ for (const t of ['OS/2', 'name', 'cvt ', 'fpgm', 'prep']) if (tables[t]) out[t] = tables[t];
173
+ const tags = Object.keys(out).sort();
174
+ const n = tags.length;
175
+ let sr = 1, es = 0;
176
+ while (sr * 2 <= n) { sr *= 2; es++; }
177
+ const dir = Buffer.alloc(12 + 16 * n);
178
+ dir.writeUInt32BE(0x00010000, 0); dir.writeUInt16BE(n, 4); dir.writeUInt16BE(sr * 16, 6); dir.writeUInt16BE(es, 8); dir.writeUInt16BE(n * 16 - sr * 16, 10);
179
+ let off = dir.length;
180
+ const bodies = [];
181
+ tags.forEach((t, i) => {
182
+ const data = out[t];
183
+ const o = 12 + 16 * i;
184
+ dir.write(t, o, 4, 'latin1'); dir.writeUInt32BE(checksum(data), o + 4); dir.writeUInt32BE(off, o + 8); dir.writeUInt32BE(data.length, o + 12);
185
+ const padded = pad4(Buffer.from(data));
186
+ bodies.push(padded); off += padded.length;
187
+ });
188
+ const file = Buffer.concat([dir, ...bodies]);
189
+ const headAt = dir.readUInt32BE(12 + 16 * tags.indexOf('head') + 8);
190
+ file.writeUInt32BE((0xb1b0afba - checksum(file)) >>> 0, headAt + 8);
191
+ return new Uint8Array(file.buffer, file.byteOffset, file.length);
192
+ }
193
+
194
+ /**
195
+ * What pdf-lib's CustomFontEmbedder / deriveFontFlags read from a fontkit font. characterSet = every
196
+ * character of the (subset) cmap, so the widths and ToUnicode cover exactly the used characters.
197
+ */
198
+ export function fontkitFont(bytes) {
199
+ const f = parseTtf(bytes);
200
+ const glyphs = new Map();
201
+ const glyph = (id, cp) => {
202
+ let g = glyphs.get(id);
203
+ if (!g) { g = { id, advanceWidth: f.advance(id), codePoints: cp === undefined ? [] : [cp] }; glyphs.set(id, g); }
204
+ return g;
205
+ };
206
+ const characterSet = [...f.cmap.keys()].sort((a, b) => a - b);
207
+ for (const c of characterSet) glyph(f.cmap.get(c), c);
208
+ const forCp = (c) => (f.cmap.has(c) ? glyph(f.cmap.get(c), c) : glyph(0));
209
+ return {
210
+ characterSet, unitsPerEm: f.unitsPerEm, postscriptName: f.postscriptName, bbox: f.bbox,
211
+ ascent: f.ascent, descent: f.descent, capHeight: f.capHeight, xHeight: f.xHeight, italicAngle: f.italicAngle, cff: false,
212
+ post: { isFixedPitch: f.isFixedPitch }, head: { macStyle: { italic: f.italic, bold: f.bold } }, 'OS/2': { sFamilyClass: f.familyClass },
213
+ glyphForCodePoint: forCp,
214
+ layout: (text) => ({ glyphs: Array.from(String(text), (ch) => forCp(ch.codePointAt(0))), positions: [] }),
215
+ };
216
+ }
217
+ /** pdfDoc.registerFontkit(MINI_FONTKIT). */
218
+ export const MINI_FONTKIT = { create: (bytes) => fontkitFont(bytes) };
219
+
220
+ // System fonts, best first; each family: regular, bold, italic, bold italic (missing ones fall back).
221
+ const FAMILIES = [
222
+ ['segoeui.ttf', 'segoeuib.ttf', 'segoeuii.ttf', 'segoeuiz.ttf'],
223
+ ['arial.ttf', 'arialbd.ttf', 'ariali.ttf', 'arialbi.ttf'],
224
+ ['tahoma.ttf', 'tahomabd.ttf'],
225
+ ['verdana.ttf', 'verdanab.ttf', 'verdanai.ttf', 'verdanaz.ttf'],
226
+ ];
227
+ const MONO = ['consola.ttf', 'cour.ttf', 'lucon.ttf'];
228
+ export const fontDir = (env = process.env) => path.join(env.WINDIR || env.SystemRoot || 'C:\\Windows', 'Fonts');
229
+
230
+ /** → { r, b, i, bi, m } parsed fonts (keys missing → fall back to r / b), or throws FontError. */
231
+ export function loadSystemFonts(dir = fontDir()) {
232
+ const load = (name) => {
233
+ try { const bytes = fs.readFileSync(path.join(dir, name)); const f = parseTtf(bytes); return f.cmap.has(0x410) ? f : null; } catch { return null; }
234
+ };
235
+ for (const [r, b, i, bi] of FAMILIES) {
236
+ const reg = load(r);
237
+ if (!reg) continue;
238
+ const out = { r: reg, b: (b && load(b)) || reg };
239
+ out.i = (i && load(i)) || reg;
240
+ out.bi = (bi && load(bi)) || out.b;
241
+ out.m = MONO.map(load).find(Boolean) || reg;
242
+ return out;
243
+ }
244
+ throw new FontError(`no usable system font (Segoe UI, Arial, Tahoma or Verdana) in ${dir}`);
245
+ }