bridgerator 1.0.8 → 1.1.1

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 +176 -110
  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 +456 -56
  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 +720 -29
  25. package/src/cli.mjs +529 -74
  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 +124 -0
  34. package/src/guard.mjs +34 -0
  35. package/src/notice.mjs +84 -23
  36. package/src/pair.mjs +27 -4
  37. package/src/pc-check.mjs +112 -0
  38. package/src/pc-check.ps1 +103 -0
  39. package/src/pc-text.mjs +120 -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 -124
  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
package/package.json CHANGED
@@ -1,49 +1,55 @@
1
- {
2
- "name": "bridgerator",
3
- "version": "1.0.8",
4
- "description": "Connect ChatGPT to your Windows PC: files, search and terminal through an outbound-only bridge with folder limits. No inbound ports, no admin rights.",
5
- "license": "MIT",
6
- "type": "module",
7
- "bin": {
8
- "bridgerator": "src/cli.mjs"
9
- },
10
- "files": [
11
- "src",
12
- "install.ps1",
13
- "README.md",
14
- "LICENSE"
15
- ],
16
- "engines": {
17
- "node": ">=20"
18
- },
19
- "scripts": {
20
- "start": "node src/cli.mjs run",
21
- "test": "node --test"
22
- },
23
- "dependencies": {
24
- "@modelcontextprotocol/sdk": "^1.29.0",
25
- "@wonderwhy-er/desktop-commander": "0.2.52",
26
- "ws": "^8.20.0"
27
- },
28
- "homepage": "https://bridgerator.recondeck.com",
29
- "keywords": [
30
- "chatgpt",
31
- "mcp",
32
- "mcp-server",
33
- "model-context-protocol",
34
- "windows",
35
- "chatgpt-connector",
36
- "ai-assistant",
37
- "remote-files",
38
- "powershell",
39
- "openai",
40
- "apps-sdk",
41
- "desktop-automation",
42
- "local-files",
43
- "bridge"
44
- ],
45
- "bugs": {
46
- "url": "https://bridgerator.recondeck.com/support"
47
- },
48
- "author": "Bridgerator <info@recondeck.com>"
49
- }
1
+ {
2
+ "name": "bridgerator",
3
+ "version": "1.1.1",
4
+ "description": "Connect ChatGPT to your Windows PC: files, search and terminal through an outbound-only bridge with folder limits. No inbound ports, no admin rights.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "bin": {
8
+ "bridgerator": "src/cli.mjs"
9
+ },
10
+ "files": [
11
+ "src",
12
+ "vendor",
13
+ "assets",
14
+ "ui",
15
+ "recipes",
16
+ "install.ps1",
17
+ "npm-shrinkwrap.json",
18
+ "README.md",
19
+ "LICENSE"
20
+ ],
21
+ "engines": {
22
+ "node": ">=22"
23
+ },
24
+ "scripts": {
25
+ "start": "node src/cli.mjs run",
26
+ "test": "node --test \"test/*.test.mjs\" test/e2e-local.mjs",
27
+ "test:stress": "node --test \"test/stress/*.test.mjs\""
28
+ },
29
+ "dependencies": {
30
+ "@modelcontextprotocol/sdk": "^1.29.0",
31
+ "@wonderwhy-er/desktop-commander": "0.2.52",
32
+ "ws": "^8.20.0"
33
+ },
34
+ "homepage": "https://bridgerator.recondeck.com",
35
+ "keywords": [
36
+ "chatgpt",
37
+ "mcp",
38
+ "mcp-server",
39
+ "model-context-protocol",
40
+ "windows",
41
+ "chatgpt-connector",
42
+ "ai-assistant",
43
+ "remote-files",
44
+ "powershell",
45
+ "openai",
46
+ "apps-sdk",
47
+ "desktop-automation",
48
+ "local-files",
49
+ "bridge"
50
+ ],
51
+ "bugs": {
52
+ "url": "https://bridgerator.recondeck.com/support"
53
+ },
54
+ "author": "Bridgerator <info@recondeck.com>"
55
+ }
@@ -0,0 +1,31 @@
1
+ {
2
+ "id": "backup-folder",
3
+ "title": { "en": "Back up a folder", "ru": "Резервная копия папки" },
4
+ "description": {
5
+ "en": "Copies a folder to another place (another disk, a USB drive, a cloud folder) with robocopy. Only new and changed files are copied. Mirror = true also removes files from the copy that are gone from the original; it is refused when the Destination already holds files that this backup did not make.",
6
+ "ru": "Копирует папку в другое место (другой диск, флешку, облачную папку) через robocopy. Копируются только новые и изменённые файлы. Mirror = true также удаляет из копии файлы, которых больше нет в оригинале; не выполняется, если в папке назначения уже есть файлы, сделанные не этой копией."
7
+ },
8
+ "params": [
9
+ { "name": "Source", "type": "path", "required": true, "access": "read", "mustExist": true, "description": { "en": "Folder to back up", "ru": "Папка, которую копировать" } },
10
+ { "name": "Destination", "type": "path", "required": true, "access": "write", "notInside": "Source", "description": { "en": "Where the copy goes (created if missing)", "ru": "Куда класть копию (создаётся, если нет)" } },
11
+ { "name": "Mirror", "type": "bool", "default": false, "description": { "en": "Also remove files from the copy that were deleted in the original", "ru": "Удалять из копии файлы, удалённые в оригинале" } }
12
+ ],
13
+ "schedule": { "type": "daily", "time": "21:00" },
14
+ "script": [
15
+ "$rc = [IO.Path]::Combine($env:SystemRoot, 'System32', 'robocopy.exe')",
16
+ "$mark = Join-Path $P.Destination '.mirror-backup'",
17
+ "# Mirror deletes for good whatever the copy has and the original has not: only into an empty / new folder or one this backup made.",
18
+ "# A freshly formatted drive root holds only these system folders: they don't count, and robocopy leaves them alone.",
19
+ "$sys = @('System Volume Information', '$RECYCLE.BIN')",
20
+ "if ($P.Mirror -and (Test-Path -LiteralPath $P.Destination) -and -not (Test-Path -LiteralPath $mark) -and @(Get-ChildItem -LiteralPath $P.Destination -Force -ErrorAction SilentlyContinue | Where-Object { $_.Name -notin $sys } | Select-Object -First 1).Count) { Write-Output 'Mirror refused: the Destination folder already holds files that this backup did not make, and Mirror would delete them for good. Choose an empty or new Destination folder, or run without Mirror.'; exit 4 }",
21
+ "# Marked BEFORE the copy: a run that fails half-way (robocopy code 8+) still leaves a folder this backup made.",
22
+ "if ($P.Mirror -and -not (Test-Path -LiteralPath $mark)) { try { [void][IO.Directory]::CreateDirectory($P.Destination); [IO.File]::WriteAllText($mark, 'Made by a mirror backup. Delete this file to stop mirroring into this folder.') } catch { Write-Output ('note: ' + $_.Exception.Message) } }",
23
+ "$a = @($P.Source, $P.Destination, '/E', '/R:1', '/W:1', '/XJ', '/FFT', '/NP', '/NFL', '/NDL', '/XF', '.mirror-backup', '/XD') + $sys",
24
+ "if ($P.Mirror) { $a += '/MIR' }",
25
+ "& $rc @a",
26
+ "$code = $LASTEXITCODE",
27
+ "if ($code -ge 8) { Write-Output (\"Backup failed: robocopy exit code \" + $code); exit $code }",
28
+ "Write-Output (\"Backup done (robocopy exit code \" + $code + \")\")",
29
+ "exit 0"
30
+ ]
31
+ }
@@ -0,0 +1,48 @@
1
+ {
2
+ "id": "clean-downloads",
3
+ "title": { "en": "Clean up old downloads", "ru": "Очистка старых загрузок" },
4
+ "description": {
5
+ "en": "Moves files older than N days from a folder (Downloads by default) to the Recycle Bin, so they can be restored. Hidden and system files are kept. A file that can't go to the Recycle Bin (network drive, too big) is kept and listed. DryRun = true only lists what would go.",
6
+ "ru": "Перемещает файлы старше N дней из папки (по умолчанию «Загрузки») в Корзину — их можно восстановить. Скрытые и системные файлы не трогаются. Файл, который нельзя положить в Корзину (сетевой диск, слишком большой), остаётся и попадает в список. DryRun = true только показывает, что будет перемещено."
7
+ },
8
+ "params": [
9
+ { "name": "Folder", "type": "path", "default": "%USERPROFILE%\\Downloads", "access": "write", "mustExist": true, "description": { "en": "Folder to clean", "ru": "Какую папку чистить" } },
10
+ { "name": "Days", "type": "int", "default": 30, "min": 1, "max": 3650, "description": { "en": "Files not changed for this many days", "ru": "Файлы, не менявшиеся столько дней" } },
11
+ { "name": "Subfolders", "type": "bool", "default": false, "description": { "en": "Also look inside subfolders", "ru": "Смотреть и во вложенных папках" } },
12
+ { "name": "DryRun", "type": "bool", "default": false, "description": { "en": "Only list, move nothing", "ru": "Только показать, ничего не перемещать" } }
13
+ ],
14
+ "schedule": { "type": "weekly", "days": ["mon"], "time": "10:00" },
15
+ "script": [
16
+ "$cut = (Get-Date).AddDays(-$P.Days)",
17
+ "$skipAttr = [IO.FileAttributes]::Hidden -bor [IO.FileAttributes]::System",
18
+ "# Own walk: junctions / symbolic links are never followed (they may lead into other folders).",
19
+ "$reparse = [IO.FileAttributes]::ReparsePoint",
20
+ "$files = New-Object System.Collections.Generic.List[object]",
21
+ "$stack = New-Object System.Collections.Generic.Stack[string]",
22
+ "$stack.Push($P.Folder)",
23
+ "while ($stack.Count) {",
24
+ " foreach ($c in @(Get-ChildItem -LiteralPath $stack.Pop() -ErrorAction SilentlyContinue)) {",
25
+ " if ($c.Attributes -band $reparse) { continue }",
26
+ " if ($c.PSIsContainer) { if ($P.Subfolders) { $stack.Push($c.FullName) }; continue }",
27
+ " if ($c.LastWriteTime -lt $cut -and $c.CreationTime -lt $cut -and -not ($c.Attributes -band $skipAttr)) { $files.Add($c) }",
28
+ " }",
29
+ "}",
30
+ "$mb = [math]::Round(([double](($files | Measure-Object Length -Sum).Sum)) / 1MB, 1)",
31
+ "if ($P.DryRun) {",
32
+ " $files | Select-Object -First 50 | ForEach-Object { Write-Output (\"would go to the Recycle Bin: \" + $_.FullName) }",
33
+ " Write-Output (\"Dry run: \" + $files.Count + \" files, \" + $mb + \" MB older than \" + $P.Days + \" days. Nothing was moved.\")",
34
+ " exit 0",
35
+ "}",
36
+ "#RECYCLE#",
37
+ "$ok = 0; $kept = 0; $err = 0",
38
+ "foreach ($f in $files) {",
39
+ " $r = [BrRecycle]::One($f.FullName)",
40
+ " if ($r -eq 'recycled') { $ok++ }",
41
+ " elseif ($r -eq 'nobin') { $kept++; Write-Output (\"kept (can't go to the Recycle Bin): \" + $f.FullName) }",
42
+ " elseif ($r -ne 'missing') { $err++; Write-Output (\"error \" + $r + \": \" + $f.FullName) }",
43
+ "}",
44
+ "Write-Output (\"Moved to the Recycle Bin: \" + $ok + \" files (\" + $mb + \" MB found); kept: \" + $kept + \"; errors: \" + $err)",
45
+ "if ($err) { exit 1 }",
46
+ "exit 0"
47
+ ]
48
+ }
@@ -0,0 +1,66 @@
1
+ {
2
+ "id": "disk-report",
3
+ "title": { "en": "Disk space report", "ru": "Отчёт о месте на диске" },
4
+ "description": {
5
+ "en": "Free space on every local disk, the biggest folders and files inside a folder (your user folder by default; folders excluded on this PC and Bridgerator's own are skipped). OutFile = also save the report to this .txt file (an existing file is kept: the report gets a free name).",
6
+ "ru": "Свободное место на каждом локальном диске, самые большие папки и файлы внутри папки (по умолчанию — папка пользователя; исключённые на этом ПК папки и папки Bridgerator пропускаются). OutFile — также сохранить отчёт в этот .txt-файл (существующий файл не трогается: отчёт получает свободное имя)."
7
+ },
8
+ "params": [
9
+ { "name": "Folder", "type": "path", "default": "%USERPROFILE%", "access": "read", "mustExist": true, "description": { "en": "Folder to look into", "ru": "Какую папку смотреть" }, "tree": "skip" },
10
+ { "name": "Top", "type": "int", "default": 15, "min": 5, "max": 50, "description": { "en": "How many folders and files to list", "ru": "Сколько папок и файлов показать" } },
11
+ { "name": "OutFile", "type": "path", "access": "write", "ext": ".txt", "description": { "en": "Also save the report here (.txt)", "ru": "Также сохранить отчёт сюда (.txt)" } }
12
+ ],
13
+ "schedule": { "type": "weekly", "days": ["fri"], "time": "12:00" },
14
+ "script": [
15
+ "$lines = New-Object System.Collections.Generic.List[string]",
16
+ "$lines.Add('Disk space report, ' + (Get-Date).ToString('yyyy-MM-dd HH:mm'))",
17
+ "foreach ($d in @(Get-CimInstance Win32_LogicalDisk -Filter 'DriveType=3')) {",
18
+ " $lines.Add(('{0} {1:N1} GB free of {2:N1} GB ({3:N0}% free)' -f $d.DeviceID, ($d.FreeSpace / 1GB), ($d.Size / 1GB), (100 * $d.FreeSpace / [math]::Max(1.0, [double]$d.Size))))",
19
+ "}",
20
+ "# \"C:\\\" stays \"C:\\\" (\"C:\" alone would be the current folder of drive C).",
21
+ "$root = $P.Folder.TrimEnd('\\')",
22
+ "if ($root -match '^[A-Za-z]:$') { $root += '\\' }",
23
+ "$base = $root.TrimEnd('\\') + '\\'",
24
+ "# FolderSkip: \"<depth>:<hash>\" of excluded folders below Folder (their names are never in the script).",
25
+ "$skip = New-Object 'System.Collections.Generic.HashSet[string]'",
26
+ "$depths = New-Object 'System.Collections.Generic.HashSet[int]'",
27
+ "foreach ($s in @($P.FolderSkip)) { if ($s) { [void]$skip.Add($s); [void]$depths.Add([int]$s.Split(':')[0]) } }",
28
+ "$sha = [Security.Cryptography.SHA256]::Create()",
29
+ "function Test-Skip([string]$rel) { $d = $rel.Split('\\').Count; if (-not $depths.Contains($d)) { return $false }; $h = [BitConverter]::ToString($sha.ComputeHash([Text.Encoding]::UTF8.GetBytes($rel.ToLowerInvariant())), 0, 8).Replace('-', '').ToLowerInvariant(); return $skip.Contains([string]$d + ':' + $h) }",
30
+ "# Own walk: excluded folders and junctions / symbolic links are never entered.",
31
+ "$all = New-Object System.Collections.Generic.List[object]",
32
+ "$stack = New-Object System.Collections.Generic.Stack[string]",
33
+ "$stack.Push($root)",
34
+ "while ($stack.Count) {",
35
+ " foreach ($c in @(Get-ChildItem -LiteralPath $stack.Pop() -Force -ErrorAction SilentlyContinue)) {",
36
+ " if ($c.Attributes -band [IO.FileAttributes]::ReparsePoint) { continue }",
37
+ " if ($c.PSIsContainer) { if (-not (Test-Skip ($c.FullName.Substring([math]::Min($c.FullName.Length, $base.Length))))) { $stack.Push($c.FullName) }; continue }",
38
+ " # An excluded FILE (.npmrc, .git-credentials, …) is never listed either.",
39
+ " if (-not (Test-Skip ($c.FullName.Substring([math]::Min($c.FullName.Length, $base.Length))))) { $all.Add($c) }",
40
+ " }",
41
+ "}",
42
+ "$groups = @{}",
43
+ "foreach ($f in $all) {",
44
+ " $rel = $f.FullName.Substring([math]::Min($f.FullName.Length, $base.Length))",
45
+ " $i = $rel.IndexOf('\\')",
46
+ " $k = if ($i -ge 0) { $rel.Substring(0, $i) } else { '(files directly here)' }",
47
+ " $groups[$k] = [double]$groups[$k] + $f.Length",
48
+ "}",
49
+ "$lines.Add('')",
50
+ "$lines.Add('Biggest folders in ' + $root + ':')",
51
+ "$groups.GetEnumerator() | Sort-Object Value -Descending | Select-Object -First $P.Top | ForEach-Object { $lines.Add(('{0,12:N1} MB {1}' -f ($_.Value / 1MB), $_.Key)) }",
52
+ "$lines.Add('')",
53
+ "$lines.Add('Biggest files:')",
54
+ "$all | Sort-Object Length -Descending | Select-Object -First $P.Top | ForEach-Object { $lines.Add(('{0,12:N1} MB {1}' -f ($_.Length / 1MB), $_.FullName)) }",
55
+ "if ($P.OutFile) {",
56
+ " # Never over an existing file: \"report.txt\" → \"report (2).txt\" …",
57
+ " $out = $P.OutFile; $n = 2",
58
+ " while ((Test-Path -LiteralPath $out) -and $n -lt 1000) { $out = Join-Path ([IO.Path]::GetDirectoryName($P.OutFile)) ([IO.Path]::GetFileNameWithoutExtension($P.OutFile) + ' (' + $n + ')' + [IO.Path]::GetExtension($P.OutFile)); $n++ }",
59
+ " $fs = New-Object IO.FileStream($out, [IO.FileMode]::CreateNew)",
60
+ " try { $w = New-Object IO.StreamWriter($fs, (New-Object Text.UTF8Encoding $true)); foreach ($l in $lines) { $w.WriteLine($l) }; $w.Flush() } finally { $fs.Dispose() }",
61
+ " $lines.Add(''); $lines.Add('Saved to ' + $out)",
62
+ "}",
63
+ "$lines | ForEach-Object { Write-Output $_ }",
64
+ "exit 0"
65
+ ]
66
+ }
@@ -0,0 +1,49 @@
1
+ {
2
+ "id": "sort-folder",
3
+ "title": { "en": "Sort a folder by file type", "ru": "Разбор папки по типам файлов" },
4
+ "description": {
5
+ "en": "Moves the files at the top of a folder (Downloads by default) into subfolders by type: Images, Documents, Archives, Video, Music, Installers. Unfinished downloads, hidden files and shortcuts stay. A name that already exists gets \" (2)\". Other = true also moves unknown types into Other. DryRun = true only lists the moves.",
6
+ "ru": "Раскладывает файлы верхнего уровня папки (по умолчанию «Загрузки») по подпапкам: изображения, документы, архивы, видео, музыка, установщики. Недокачанные, скрытые файлы и ярлыки остаются. При совпадении имени добавляется « (2)». Other = true также переносит неизвестные типы в «Другое». DryRun = true только показывает перемещения."
7
+ },
8
+ "params": [
9
+ { "name": "Folder", "type": "path", "default": "%USERPROFILE%\\Downloads", "access": "write", "mustExist": true, "description": { "en": "Folder to sort", "ru": "Какую папку разобрать" } },
10
+ { "name": "Language", "type": "enum", "values": ["en", "ru"], "default": "en", "description": { "en": "Language of the subfolder names", "ru": "Язык названий подпапок" } },
11
+ { "name": "Other", "type": "bool", "default": false, "description": { "en": "Move unknown file types into Other", "ru": "Переносить неизвестные типы в «Другое»" } },
12
+ { "name": "DryRun", "type": "bool", "default": false, "description": { "en": "Only list, move nothing", "ru": "Только показать, ничего не перемещать" } }
13
+ ],
14
+ "schedule": { "type": "daily", "time": "18:00" },
15
+ "script": [
16
+ "$names = @{",
17
+ " en = @{ img = 'Images'; doc = 'Documents'; arc = 'Archives'; vid = 'Video'; aud = 'Music'; app = 'Installers'; other = 'Other' }",
18
+ " ru = @{ img = 'Изображения'; doc = 'Документы'; arc = 'Архивы'; vid = 'Видео'; aud = 'Музыка'; app = 'Установщики'; other = 'Другое' }",
19
+ "}[$P.Language]",
20
+ "$map = @{}",
21
+ "foreach ($e in 'jpg','jpeg','png','gif','bmp','webp','heic','heif','svg','tif','tiff','ico','raw','cr2','nef') { $map[$e] = 'img' }",
22
+ "foreach ($e in 'pdf','doc','docx','xls','xlsx','ppt','pptx','txt','rtf','odt','ods','odp','csv','md','epub','djvu','fb2') { $map[$e] = 'doc' }",
23
+ "foreach ($e in 'zip','rar','7z','tar','gz','bz2','xz','iso','cab') { $map[$e] = 'arc' }",
24
+ "foreach ($e in 'mp4','mkv','avi','mov','wmv','webm','m4v','flv') { $map[$e] = 'vid' }",
25
+ "foreach ($e in 'mp3','wav','flac','aac','ogg','m4a','wma','opus') { $map[$e] = 'aud' }",
26
+ "foreach ($e in 'exe','msi','msix','msixbundle','appx','appxbundle') { $map[$e] = 'app' }",
27
+ "$skip = @('crdownload','part','partial','tmp','download','opdownload','ini','lnk','url')",
28
+ "$skipAttr = [IO.FileAttributes]::Hidden -bor [IO.FileAttributes]::System",
29
+ "$n = 0; $err = 0",
30
+ "foreach ($f in @(Get-ChildItem -LiteralPath $P.Folder -File -ErrorAction SilentlyContinue)) {",
31
+ " if ($f.Attributes -band $skipAttr) { continue }",
32
+ " $ext = $f.Extension.TrimStart('.').ToLower()",
33
+ " if ($skip -contains $ext) { continue }",
34
+ " $k = $map[$ext]",
35
+ " if (-not $k) { if (-not $P.Other) { continue }; $k = 'other' }",
36
+ " $to = Join-Path $P.Folder $names[$k]",
37
+ " $dest = Join-Path $to $f.Name",
38
+ " $i = 2",
39
+ " while (Test-Path -LiteralPath $dest) { $dest = Join-Path $to ($f.BaseName + ' (' + $i + ')' + $f.Extension); $i++ }",
40
+ " if ($P.DryRun) { Write-Output ('would move: ' + $f.Name + ' -> ' + $names[$k]); $n++; continue }",
41
+ " try { [void][IO.Directory]::CreateDirectory($to); Move-Item -LiteralPath $f.FullName -Destination $dest -ErrorAction Stop; $n++ }",
42
+ " catch { $err++; Write-Output ('error: ' + $f.Name + ': ' + $_.Exception.Message) }",
43
+ "}",
44
+ "if ($P.DryRun) { Write-Output ('Dry run: ' + $n + ' files would be moved. Nothing was moved.'); exit 0 }",
45
+ "Write-Output ('Sorted: ' + $n + ' files moved; errors: ' + $err)",
46
+ "if ($err) { exit 1 }",
47
+ "exit 0"
48
+ ]
49
+ }
@@ -0,0 +1,25 @@
1
+ {
2
+ "id": "update-apps",
3
+ "title": { "en": "Update apps (winget)", "ru": "Обновление программ (winget)" },
4
+ "description": {
5
+ "en": "Uses Windows Package Manager (winget). Mode list = only show which apps have updates; all = update every app winget can update silently. Apps installed for all users may need administrator rights and are then skipped or ask. AcceptAgreements = true only after the person agreed to the program and source license terms.",
6
+ "ru": "Использует диспетчер пакетов Windows (winget). Mode list — только показать, для каких программ есть обновления; all — обновить все программы, которые winget может обновить тихо. Программы, установленные для всех пользователей, могут требовать прав администратора — тогда они пропускаются или спрашивают. AcceptAgreements = true — только после того, как человек согласился с условиями лицензий программ и источников."
7
+ },
8
+ "params": [
9
+ { "name": "Mode", "type": "enum", "values": ["list", "all"], "default": "list", "description": { "en": "list = show updates, all = install them", "ru": "list — показать обновления, all — установить" } },
10
+ { "name": "AcceptAgreements", "type": "bool", "default": false, "description": { "en": "Accept the license terms of the programs and the source (only after the person agreed)", "ru": "Принять условия лицензий программ и источника (только после согласия человека)" } }
11
+ ],
12
+ "schedule": { "type": "weekly", "days": ["sat"], "time": "11:00" },
13
+ "script": [
14
+ "# winget only from App Installer's alias of this user, never from the PATH.",
15
+ "$winget = [IO.Path]::Combine([Environment]::GetFolderPath('LocalApplicationData'), 'Microsoft', 'WindowsApps', 'winget.exe')",
16
+ "if (-not (Test-Path -LiteralPath $winget)) { Write-Output 'winget (App Installer) is not available on this PC.'; exit 3 }",
17
+ "$acc = @()",
18
+ "if ($P.AcceptAgreements) { $acc = @('--accept-source-agreements'); if ($P.Mode -eq 'all') { $acc += '--accept-package-agreements' } }",
19
+ "if ($P.Mode -eq 'all') { & $winget upgrade --all --silent @acc --disable-interactivity }",
20
+ "else { & $winget upgrade @acc --disable-interactivity }",
21
+ "$code = $LASTEXITCODE",
22
+ "if ($code -eq -1978335189) { Write-Output 'No updates found.'; exit 0 }",
23
+ "exit $code"
24
+ ]
25
+ }
@@ -0,0 +1,230 @@
1
+ # Bridgerator: the question on the PC (toast / fallback window). Shared by ask.ps1 (one question per
2
+ # process) and the tray helper. Loaded as text into a scriptblock (not subject to the execution policy).
3
+ function X([string]$s) { [Security.SecurityElement]::Escape($s) }
4
+ # $q (one question): specs = @(@(key, label, kind)...), head, detail, attr, host, auto, secs, tag, theme, scale, shot, lang (en | ru)
5
+
6
+ # Compiled C# helpers are cached in %LOCALAPPDATA%\Bridgerator\cache\<hash of the code>.dll and
7
+ # loaded from there (compiling on every start costs ~1 s). A failed cache falls back to compiling.
8
+ function Add-CachedType([string]$code, [string[]]$refs = @()) {
9
+ try {
10
+ $dir = Join-Path $env:LOCALAPPDATA 'Bridgerator\cache'
11
+ $bytes = [Text.Encoding]::UTF8.GetBytes("$code|$($refs -join ',')|$($PSVersionTable.CLRVersion)")
12
+ $key = -join ([Security.Cryptography.SHA256]::Create().ComputeHash($bytes)[0..11] | ForEach-Object { $_.ToString('x2') })
13
+ $dll = Join-Path $dir "br-$key.dll"
14
+ if (-not [IO.File]::Exists($dll)) {
15
+ [void][IO.Directory]::CreateDirectory($dir)
16
+ $tmp = Join-Path $dir "br-$key.$PID.dll"
17
+ if ($refs.Count) { Add-Type -TypeDefinition $code -ReferencedAssemblies $refs -OutputAssembly $tmp -OutputType Library } else { Add-Type -TypeDefinition $code -OutputAssembly $tmp -OutputType Library }
18
+ # Another process compiled the same code first: use its file, remove ours (not loaded yet).
19
+ try { [IO.File]::Move($tmp, $dll) } catch { if ([IO.File]::Exists($dll)) { try { [IO.File]::Delete($tmp) } catch { } } else { $dll = $tmp } }
20
+ }
21
+ Add-Type -Path $dll
22
+ } catch {
23
+ if ($refs.Count) { Add-Type -TypeDefinition $code -ReferencedAssemblies $refs } else { Add-Type -TypeDefinition $code }
24
+ }
25
+ }
26
+
27
+ # Event sink in C#: PowerShell 5.1 can't subscribe to WinRT events itself (Register-ObjectEvent
28
+ # refuses), but a compiled delegate can. Finish() runs once: by a click, a dismissal or the timeout.
29
+ # With Report set (tray helper) the answer goes straight to stdout as a JSON line.
30
+ Add-CachedType @'
31
+ using System; using System.Threading; using System.Text.RegularExpressions;
32
+ public static class BrIO {
33
+ static readonly object L = new object();
34
+ public static void Send(string line) { lock (L) { Console.Out.WriteLine(line); Console.Out.Flush(); } }
35
+ }
36
+ public class BrWait {
37
+ public string Id; public string[] Keys = new string[0]; public bool Report; public string Result; public string FailCode; public ManualResetEvent Done = new ManualResetEvent(false);
38
+ readonly object l = new object(); bool over;
39
+ public bool Finish(string key) {
40
+ lock (l) { if (over) return false; over = true; }
41
+ Result = Array.IndexOf(Keys, key) >= 0 ? key : (key == "failed" ? "failed" : "deny");
42
+ if (Report && Result != "failed" && Regex.IsMatch(Id ?? "", "^[A-Za-z0-9-]{1,40}$")) BrIO.Send("{\"t\":\"answer\",\"id\":\"" + Id + "\",\"key\":\"" + Result + "\"}");
43
+ Done.Set(); return true;
44
+ }
45
+ public bool IsOver { get { lock (l) return over; } }
46
+ public void OnActivated(object s, object a) { var p = a == null ? null : a.GetType().GetProperty("Arguments"); Finish(p == null ? "" : (string)p.GetValue(a, null)); }
47
+ public void OnDismissed(object s, object a) { var p = a == null ? null : a.GetType().GetProperty("Reason"); if (p != null && p.GetValue(a, null).ToString() == "UserCanceled") Finish("deny"); }
48
+ public void OnFailed(object s, object a) { try { var p = a == null ? null : a.GetType().GetProperty("ErrorCode"); var e = p == null ? null : p.GetValue(a, null) as Exception; if (e != null) FailCode = "0x" + e.HResult.ToString("X8"); } catch {} Finish("failed"); }
49
+ }
50
+ '@
51
+
52
+ # The Bridgerator logo "Arc" (assets/logo.svg, 64x64): blue rounded square, white arc, a white and a
53
+ # cyan dot. Small sizes (<= 24 px) get a pixel-snapped variant: stroke and dots at least 2 px.
54
+ function New-LogoBitmap([int]$size) {
55
+ Add-Type -AssemblyName System.Drawing
56
+ $bm = New-Object Drawing.Bitmap $size, $size
57
+ $g = [Drawing.Graphics]::FromImage($bm); $g.SmoothingMode = 'AntiAlias'; $g.PixelOffsetMode = 'HighQuality'
58
+ $k = $size / 64.0
59
+ $r = 15 * $k * 2; $w = [single]$size
60
+ $p = New-Object Drawing.Drawing2D.GraphicsPath
61
+ $p.AddArc(0, 0, $r, $r, 180, 90); $p.AddArc($w - $r, 0, $r, $r, 270, 90); $p.AddArc($w - $r, $w - $r, $r, $r, 0, 90); $p.AddArc(0, $w - $r, $r, $r, 90, 90); $p.CloseFigure()
62
+ $g.FillPath((New-Object Drawing.SolidBrush ([Drawing.ColorTranslator]::FromHtml('#2251E6'))), $p)
63
+ $small = $size -le 24
64
+ $sw = if ($small) { [Math]::Max(2, [Math]::Round(5.5 * $k)) } else { 5.5 * $k }
65
+ $dr = if ($small) { [Math]::Max(2, [Math]::Round(6.5 * $k * 2) / 2) } else { 6.5 * $k }
66
+ $snap = { param($v) if ($small) { [Math]::Round($v * 2) / 2 } else { $v } }
67
+ $x1 = & $snap (17 * $k); $x2 = & $snap (47 * $k); $y = & $snap (42 * $k); $cy = & $snap (44 * $k)
68
+ $pen = New-Object Drawing.Pen ([Drawing.Color]::White), $sw; $pen.StartCap = 'Round'; $pen.EndCap = 'Round'
69
+ # Quadratic M17 42 Q32 12 47 42 as a cubic: control points 2/3 of the way to (32,12).
70
+ $g.DrawBezier($pen, $x1, $y, (27 * $k), (22 * $k), (37 * $k), (22 * $k), $x2, $y)
71
+ $g.FillEllipse([Drawing.Brushes]::White, ($x1 - $dr), ($cy - $dr), (2 * $dr), (2 * $dr))
72
+ $g.FillEllipse((New-Object Drawing.SolidBrush ([Drawing.ColorTranslator]::FromHtml('#7fe0ff'))), ($x2 - $dr), ($cy - $dr), (2 * $dr), (2 * $dr))
73
+ $g.Dispose(); return $bm
74
+ }
75
+
76
+ # The process is started hidden (windowsHide): Windows applies that to the FIRST window shown by the
77
+ # process. Spend it on an invisible throwaway form so our real windows show normally.
78
+ function Initialize-Windows {
79
+ Add-Type -AssemblyName System.Windows.Forms
80
+ $d = New-Object Windows.Forms.Form; $d.ShowInTaskbar = $false; $d.Opacity = 0; $d.Size = New-Object Drawing.Size(1, 1); $d.StartPosition = 'Manual'; $d.Location = New-Object Drawing.Point(-2000, -2000)
81
+ $d.Show(); $d.Close(); $d.Dispose()
82
+ }
83
+
84
+ # Our app id under HKCU (no admin): the notification header reads "Bridgerator" with our icon.
85
+ function Register-AppId([string]$icon) {
86
+ $k = 'HKCU:\Software\Classes\AppUserModelId\Bridgerator'
87
+ if (-not (Test-Path $k)) { New-Item $k -Force | Out-Null }
88
+ New-ItemProperty $k -Name DisplayName -Value 'Bridgerator' -Force | Out-Null
89
+ if ($icon -and (Test-Path $icon)) { New-ItemProperty $k -Name IconUri -Value $icon -Force | Out-Null }
90
+ }
91
+
92
+ # Shows the toast; the answer arrives in $w (BrWait). Throws when our notifications are off.
93
+ function Show-AskToast($q, $w) {
94
+ Register-AppId $q.icon
95
+ [Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] > $null
96
+ [Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime] > $null
97
+ $n = [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('Bridgerator')
98
+ # Only an explicit Disabled* value means off: Windows 10 (PowerShell 5.1) can return no value at all (VM 21.10).
99
+ if ([string]$n.Setting -like 'Disabled*') { throw "notifications for Bridgerator are off ($($n.Setting))" }
100
+ # Three buttons fit on one line; "Allow always" goes to the toast's "..." / right-click menu.
101
+ $btns = ($q.specs | ForEach-Object {
102
+ $extra = if ($_[2] -eq 'deny') { " hint-buttonStyle='Critical'" } elseif ($_[0] -eq 'always') { " placement='contextMenu'" } else { '' }
103
+ $label = if ($_[0] -eq 'always') { "$($_[1]) $(if ($q.lang -eq 'ru') { '(больше не спрашивать)' } else { '(stop asking for this)' })" } else { $_[1] }
104
+ "<action content='$(X $label)' arguments='$(X $_[0])' activationType='foreground'$extra/>" }) -join ''
105
+ $logo = if ($q.icon -and (Test-Path $q.icon)) { "<image placement='appLogoOverride' src='$(X ([Uri]$q.icon).AbsoluteUri)'/>" } else { '' }
106
+ $audio = if ($q.silent) { "<audio silent='true'/>" } else { "<audio src='ms-winsoundevent:Notification.Default'/>" }
107
+ $xml = "<toast scenario='reminder' useButtonStyle='true' activationType='foreground' launch='open'><visual><binding template='ToastGeneric'>$logo<text>$(X $q.head)</text><text>$(X $q.detail)</text><text placement='attribution'>$(X $q.attr)</text></binding></visual><actions>$btns</actions>$audio</toast>"
108
+ $doc = New-Object Windows.Data.Xml.Dom.XmlDocument; $doc.LoadXml($xml)
109
+ $toast = [Windows.UI.Notifications.ToastNotification]::new($doc); $toast.Tag = $q.tag; $toast.Group = 'bridgerator'
110
+ foreach ($ev in 'Activated', 'Dismissed', 'Failed') {
111
+ $h = [Delegate]::CreateDelegate($toast.GetType().GetEvent($ev).EventHandlerType, $w, $w.GetType().GetMethod("On$ev"))
112
+ [void]$toast."add_$ev"($h)
113
+ }
114
+ $n.Show($toast)
115
+ }
116
+ function Remove-AskToast($tag) { try { [Windows.UI.Notifications.ToastNotificationManager]::History.Remove($tag, 'bridgerator', 'Bridgerator') } catch {} }
117
+
118
+ # Fallback: Windows 11 notification metrics (logical px x DPI scale), bottom-right above the taskbar.
119
+ function New-AskForm($q) {
120
+ Add-Type -AssemblyName System.Windows.Forms, System.Drawing
121
+ $f = $null
122
+ try {
123
+ Add-CachedType -refs System.Windows.Forms, System.Drawing -code @'
124
+ using System; using System.Windows.Forms; using System.Runtime.InteropServices;
125
+ // Only real input clicks: a click injected by a program (SendInput) or an accessibility "Invoke" /
126
+ // BM_CLICK never counts (GetCurrentInputMessageSource must say: hardware).
127
+ public class BrRealButton : Button {
128
+ [StructLayout(LayoutKind.Sequential)] struct IMS { public int deviceType; public int originId; }
129
+ [DllImport("user32.dll")] static extern bool GetCurrentInputMessageSource(out IMS s);
130
+ bool real;
131
+ protected override void WndProc(ref Message m) {
132
+ if (m.Msg == 0x0202 || m.Msg == 0x0101) { IMS s; real = GetCurrentInputMessageSource(out s) && s.originId == 1; }
133
+ base.WndProc(ref m);
134
+ }
135
+ protected override void OnClick(EventArgs e) { if (!real) return; real = false; base.OnClick(e); }
136
+ }
137
+ public class BrToastForm : Form {
138
+ protected override bool ShowWithoutActivation { get { return true; } }
139
+ protected override CreateParams CreateParams { get { var p = base.CreateParams; p.ExStyle |= 0x08000008; return p; } }
140
+ [DllImport("user32.dll")] public static extern bool SetProcessDPIAware();
141
+ [DllImport("dwmapi.dll")] public static extern int DwmSetWindowAttribute(IntPtr h, int a, ref int v, int s);
142
+ [DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr h, int c);
143
+ }
144
+ '@
145
+ [void][BrToastForm]::SetProcessDPIAware()
146
+ $f = New-Object BrToastForm
147
+ } catch { $f = New-Object Windows.Forms.Form }
148
+ [Windows.Forms.Application]::EnableVisualStyles()
149
+ $dark = $false
150
+ try { $dark = (Get-ItemProperty 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Themes\Personalize' -ErrorAction Stop).AppsUseLightTheme -eq 0 } catch {}
151
+ if ($q.theme -eq 'dark') { $dark = $true } elseif ($q.theme -eq 'light') { $dark = $false }
152
+ $rgb = { param($h) [Drawing.ColorTranslator]::FromHtml($h) }
153
+ $bg = & $rgb $(if ($dark) { '#2b2b2b' } else { '#f9f9f9' }); $border = & $rgb $(if ($dark) { '#3a3a3a' } else { '#e5e5e5' })
154
+ $fg = & $rgb $(if ($dark) { '#ffffff' } else { '#1b1b1b' }); $dim = & $rgb $(if ($dark) { '#a8a8a8' } else { '#5f5f5f' })
155
+ $btnBg = & $rgb $(if ($dark) { '#3a3a3a' } else { '#ffffff' }); $btn2 = & $rgb $(if ($dark) { '#323232' } else { '#f3f3f3' }); $btnLine = & $rgb $(if ($dark) { '#4a4a4a' } else { '#d0d0d0' })
156
+ $red = & $rgb $(if ($dark) { '#ff99a4' } else { '#c42b1c' })
157
+ $g0 = [Drawing.Graphics]::FromHwnd([IntPtr]::Zero); $sc = $g0.DpiX / 96.0; $g0.Dispose()
158
+ if ($q.scale) { $sc = [double]$q.scale }
159
+ $px = { param($v) [int][Math]::Round($v * $sc) }
160
+ $font = { param($name, $size, $style = 'Regular') New-Object Drawing.Font($name, [single]($size * $sc), [Drawing.FontStyle]$style, [Drawing.GraphicsUnit]::Pixel) }
161
+ $fApp = & $font 'Segoe UI' 12; $fTitle = & $font 'Segoe UI Semibold' 14; $fCmd = & $font 'Consolas' 11; $fMeta = & $font 'Segoe UI' 11; $fBtn = & $font 'Segoe UI' 12
162
+ $pad = & $px 16; $gap = & $px 8; $bh = & $px 32
163
+ # Width: 380, wider only when a button label would not fit on one line.
164
+ $rows = New-Object Collections.ArrayList
165
+ foreach ($primary in $true, $false) { $row = @($q.specs | Where-Object { ($_[0] -in 'allow', 'deny') -eq $primary }); if ($row.Count) { [void]$rows.Add($row) } }
166
+ $W = & $px 380
167
+ foreach ($s in $q.specs) { $need = 2 * ([Windows.Forms.TextRenderer]::MeasureText($s[1], $fBtn).Width + (& $px 24)) + $gap + 2 * $pad; if ($need -gt $W) { $W = $need } }
168
+ $cw = $W - 2 * $pad; $bw = [int](($cw - $gap) / 2)
169
+ foreach ($s in $q.specs) { if ([Windows.Forms.TextRenderer]::MeasureText($s[1], $fBtn).Width + (& $px 24) -gt $bw) { throw "button label does not fit: $($s[1])" } }
170
+
171
+ $f.FormBorderStyle = 'None'; $f.ShowInTaskbar = $false; $f.TopMost = $true; $f.StartPosition = 'Manual'; $f.AutoScaleMode = 'None'
172
+ $f.BackColor = $bg; $f.ForeColor = $fg; $f.Text = 'Bridgerator'; $f.KeyPreview = $true
173
+ $y = $pad
174
+ $add = { param($c) $f.Controls.Add($c) }
175
+ # App line: icon + "Bridgerator"
176
+ $ic = New-LogoBitmap (& $px 16); $qs = $ic.Width
177
+ $pb = New-Object Windows.Forms.PictureBox; $pb.Image = $ic; $pb.SetBounds($pad, $y + (& $px 1), $qs, $qs); & $add $pb
178
+ $al = New-Object Windows.Forms.Label; $al.Text = 'Bridgerator'; $al.Font = $fApp; $al.ForeColor = $dim; $al.AutoSize = $true; $al.UseMnemonic = $false; $al.Location = New-Object Drawing.Point(($pad + $qs + (& $px 8)), $y); & $add $al
179
+ $y += [Math]::Max($qs, $al.PreferredHeight) + (& $px 10)
180
+ # Title
181
+ $tl = New-Object Windows.Forms.Label; $tl.UseMnemonic = $false; $tl.Font = $fTitle; $tl.ForeColor = $fg; $tl.Text = $q.head
182
+ $th = [Windows.Forms.TextRenderer]::MeasureText($tl.Text, $fTitle, (New-Object Drawing.Size($cw, 0)), [Windows.Forms.TextFormatFlags]::WordBreak).Height
183
+ $tl.SetBounds($pad, $y, $cw, $th); & $add $tl; $y += $th + (& $px 6)
184
+ # Command / path: a Label (not selectable), at most 3 lines, then "…"
185
+ $flags = [Windows.Forms.TextFormatFlags]::WordBreak -bor [Windows.Forms.TextFormatFlags]::TextBoxControl
186
+ $lineH = [Windows.Forms.TextRenderer]::MeasureText('Ag', $fCmd).Height
187
+ $txt = [string]$q.detail
188
+ while ($txt.Length -gt 1 -and [Windows.Forms.TextRenderer]::MeasureText($txt, $fCmd, (New-Object Drawing.Size($cw, 0)), $flags).Height -gt 3 * $lineH) { $txt = $txt.Substring(0, $txt.Length - 2).TrimEnd() + [char]0x2026 }
189
+ $ch = [Windows.Forms.TextRenderer]::MeasureText($txt, $fCmd, (New-Object Drawing.Size($cw, 0)), $flags).Height
190
+ $cl = New-Object Windows.Forms.Label; $cl.UseMnemonic = $false; $cl.Font = $fCmd; $cl.ForeColor = $fg; $cl.Text = $txt; $cl.SetBounds($pad, $y, $cw, $ch); & $add $cl; $y += $ch + (& $px 6)
191
+ # Meta line with the countdown
192
+ $ml = New-Object Windows.Forms.Label; $ml.UseMnemonic = $false; $ml.Font = $fMeta; $ml.ForeColor = $dim; $ml.AutoSize = $false
193
+ $st = @{ pick = 'deny'; left = [int]$q.secs }; $f.Tag = $st
194
+ $hostName = $q.host; $autoWord = $q.auto; $ru = $q.lang -eq 'ru'
195
+ $tick = { $ml.Text = $(if ($ru) { "$hostName · $autoWord через $($st.left) с" } else { "$hostName · $autoWord in $($st.left) s" }) }.GetNewClosure(); & $tick
196
+ $mh = [Windows.Forms.TextRenderer]::MeasureText('Ag', $fMeta).Height; $ml.SetBounds($pad, $y, $cw, $mh); & $add $ml; $y += $mh + (& $px 12)
197
+ # Buttons: two rows, two per row, never wrapping
198
+ foreach ($row in $rows) {
199
+ $x = $pad
200
+ foreach ($s in $row) {
201
+ $b = if ('BrRealButton' -as [type]) { New-Object BrRealButton } else { New-Object Windows.Forms.Button }; $b.Text = $s[1]; $b.Tag = $s[0]; $b.Font = $fBtn; $b.FlatStyle = 'Flat'; $b.UseMnemonic = $false; $b.TabStop = $false
202
+ $b.AutoEllipsis = $false; $b.UseCompatibleTextRendering = $false; $b.SetBounds($x, $y, $bw, $bh); $b.Cursor = 'Hand'
203
+ $primary = $s[0] -in 'allow', 'deny'
204
+ $b.BackColor = $(if ($primary) { $btnBg } else { $btn2 }); $b.ForeColor = $fg; $b.FlatAppearance.BorderColor = $btnLine
205
+ if ($s[2] -eq 'deny') { $b.FlatAppearance.BorderColor = $red; $b.ForeColor = $red; $f.CancelButton = $b }
206
+ $b.Add_Click({ $f.Tag.pick = $this.Tag; $f.Close() }.GetNewClosure())
207
+ & $add $b; $x += $bw + $gap
208
+ }
209
+ $y += $bh + $gap
210
+ }
211
+ $H = $y - $gap + $pad
212
+ $f.ClientSize = New-Object Drawing.Size($W, $H)
213
+ $wa = [Windows.Forms.Screen]::PrimaryScreen.WorkingArea; $m = & $px 12
214
+ $f.Location = New-Object Drawing.Point(($wa.Right - $W - $m), ($wa.Bottom - $H - $m))
215
+ $f.Add_Paint({ $_.Graphics.DrawRectangle((New-Object Drawing.Pen $border), 0, 0, $f.ClientSize.Width - 1, $f.ClientSize.Height - 1) }.GetNewClosure())
216
+ $f.Add_KeyDown({ if ($_.KeyCode -eq 'Escape') { $f.Tag.pick = 'deny'; $f.Close() } }.GetNewClosure())
217
+ $f.Add_HandleCreated({ try { $v = 2; [void][BrToastForm]::DwmSetWindowAttribute($f.Handle, 33, [ref]$v, 4) } catch {} }.GetNewClosure())
218
+ # Started hidden (windowsHide): show it explicitly, without taking the focus.
219
+ $f.Add_Shown({ try { [void][BrToastForm]::ShowWindow($f.Handle, 4) } catch {} }.GetNewClosure())
220
+ $tm = New-Object Windows.Forms.Timer; $tm.Interval = 1000
221
+ $tm.Add_Tick({ $st.left--; & $tick; if ($st.left -le 0) { $f.Close() } }.GetNewClosure()); $tm.Start()
222
+ # Buttons answer only after 600 ms: no accidental click on a window that just popped up.
223
+ $btns = @($f.Controls | Where-Object { $_ -is [Windows.Forms.Button] }); foreach ($b in $btns) { $b.Enabled = $false }
224
+ $en = New-Object Windows.Forms.Timer; $en.Interval = 600; $en.Add_Tick({ $en.Stop(); foreach ($b in $btns) { $b.Enabled = $true } }.GetNewClosure()); $en.Start()
225
+ $f.Add_FormClosed({ $tm.Stop(); $tm.Dispose() }.GetNewClosure())
226
+ if ($q.shot) { $shot = $q.shot; $sh = New-Object Windows.Forms.Timer; $sh.Interval = 1200; $sh.Add_Tick({ $sh.Stop(); $bm = New-Object Drawing.Bitmap $f.Width, $f.Height; $gg = [Drawing.Graphics]::FromImage($bm); $gg.CopyFromScreen($f.Location, [Drawing.Point]::Empty, $f.Size); $bm.Save($shot); $f.Close() }.GetNewClosure()); $sh.Start() }
227
+ return $f
228
+ }
229
+
230
+