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/src/tray.ps1 ADDED
@@ -0,0 +1,359 @@
1
+ # Bridgerator tray helper: started hidden by the bridge (one per Windows user), talks to it over
2
+ # stdin/stdout JSON lines, exits when the bridge process is gone. Loaded after ask-lib.ps1 (the
3
+ # toast / fallback window). No network, no listening socket.
4
+ # bridge -> helper: {t:'state', state:'online'|'offline'|'paused', name, todo, confirm:'on'|'off', theme, lang:'en'|'ru', plan:{label,url,badge}|null, awake:bool}
5
+ # {t:'ask', id, head, detail, attr, host, auto, secs, theme, silent, style, lang, buttons:[[key,label,kind]...]}
6
+ # {t:'rpc-result', id, result | error} (answers to the window) {t:'open'}
7
+ # helper -> bridge: {t:'ready'} {t:'answer', id, key} {t:'log', msg} {t:'power', on, ac} (keep-awake as applied)
8
+ # {t:'menu', action:'pause'(minutes?)|'resume'|'quit'|'confirm'(on)}
9
+ # {t:'rpc', id, method, params, paths} (from the window)
10
+ $ErrorActionPreference = 'Stop'
11
+ [Console]::InputEncoding = [Text.UTF8Encoding]::new($false)
12
+ [Console]::OutputEncoding = [Text.UTF8Encoding]::new($false)
13
+ Add-Type -AssemblyName System.Windows.Forms, System.Drawing
14
+ Add-CachedType -refs System.Windows.Forms -code @'
15
+ using System; using System.Collections.Concurrent; using System.Threading; using System.Runtime.InteropServices;
16
+ public static class BrIn {
17
+ public static readonly ConcurrentQueue<string> Lines = new ConcurrentQueue<string>();
18
+ public static volatile bool Closed;
19
+ public static void Start() {
20
+ var t = new Thread(() => { try { string l; while ((l = Console.In.ReadLine()) != null) Lines.Enqueue(l); } catch {} Closed = true; });
21
+ t.IsBackground = true; t.Start();
22
+ }
23
+ }
24
+ public static class BrWin {
25
+ [DllImport("user32.dll")] public static extern bool SetProcessDPIAware();
26
+ [DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr h, int c);
27
+ [DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr h);
28
+ // GetHicon makes a handle that Icon.Dispose / FromHandle never free: destroyed here after the icon is replaced.
29
+ [DllImport("user32.dll")] public static extern bool DestroyIcon(IntPtr h);
30
+ [DllImport("user32.dll", CharSet = CharSet.Unicode)] public static extern bool SetWindowText(IntPtr h, string s);
31
+ [DllImport("user32.dll")] static extern int GetWindowLong(IntPtr h, int i);
32
+ [DllImport("user32.dll")] static extern int SetWindowLong(IntPtr h, int i, int v);
33
+ [DllImport("user32.dll")] static extern bool SetWindowPos(IntPtr h, IntPtr a, int x, int y, int w, int hh, uint f);
34
+ // Resizable frame, no caption (the page has its own title bar); the title stays for the taskbar.
35
+ public static void NoCaption(IntPtr h) {
36
+ int st = GetWindowLong(h, -16); st &= ~0x00C00000; st |= 0x00040000 | 0x00020000 | 0x00080000; SetWindowLong(h, -16, st);
37
+ SetWindowPos(h, IntPtr.Zero, 0, 0, 0, 0, 0x0027);
38
+ }
39
+ [DllImport("dwmapi.dll")] public static extern int DwmSetWindowAttribute(IntPtr h, int a, ref int v, int s);
40
+ // The page's title bar moves the window: the native move loop, started as if the caption was pressed.
41
+ [DllImport("user32.dll")] static extern bool ReleaseCapture();
42
+ [DllImport("user32.dll")] static extern IntPtr SendMessage(IntPtr h, int m, IntPtr w, IntPtr l);
43
+ public static void Drag(IntPtr h) { ReleaseCapture(); SendMessage(h, 0xA1, (IntPtr)2, IntPtr.Zero); }
44
+ // The window for the page's requests: a nested .GetNewClosure() handler does not see $f (it was $null → "Minimize" did nothing).
45
+ public static object MainForm, Frame, Post;
46
+ // Keep-awake: ES_CONTINUOUS (| ES_SYSTEM_REQUIRED) on the UI thread, which lives as long as the helper.
47
+ [DllImport("kernel32.dll")] static extern uint SetThreadExecutionState(uint f);
48
+ public static void Awake(bool on) { SetThreadExecutionState(on ? 0x80000001u : 0x80000000u); }
49
+ }
50
+ // Without a caption Windows still draws the top resize border as a dark / light strip above the page:
51
+ // WM_NCCALCSIZE keeps the client top where the window top is (sides and bottom still resize).
52
+ public class BrFrame : System.Windows.Forms.NativeWindow {
53
+ [DllImport("user32.dll")] static extern bool IsZoomed(IntPtr h);
54
+ [DllImport("user32.dll")] static extern int GetSystemMetrics(int i);
55
+ public BrFrame(IntPtr h) { AssignHandle(h); }
56
+ protected override void WndProc(ref System.Windows.Forms.Message m) {
57
+ if (m.Msg == 0x83 && m.WParam != IntPtr.Zero) {
58
+ int top = Marshal.ReadInt32(m.LParam, 4);
59
+ base.WndProc(ref m);
60
+ Marshal.WriteInt32(m.LParam, 4, top + (IsZoomed(m.HWnd) ? GetSystemMetrics(33) + GetSystemMetrics(92) : 0));
61
+ return;
62
+ }
63
+ base.WndProc(ref m);
64
+ }
65
+ }
66
+ '@
67
+ [void][BrWin]::SetProcessDPIAware()
68
+ [Windows.Forms.Application]::EnableVisualStyles()
69
+ Initialize-Windows
70
+ $parent = [int]$env:BR_PARENT
71
+ # The account page of the site this PC is linked to (BR_ACCOUNT from tray.mjs: staging opens staging), else the main site.
72
+ $account = if ($env:BR_ACCOUNT -match '^https://[a-z0-9.-]+(:\d+)?/app$') { $env:BR_ACCOUNT } else { 'https://bridgerator.recondeck.com/app' }
73
+ $siteHost = ([Uri]$account).Host
74
+ $send = { param($o) [BrIO]::Send(($o | ConvertTo-Json -Compress -Depth 6)) }
75
+ # Links from the window / menu: only our own site (the main one, or the linked one), opened in the default browser.
76
+ $openUrl = { param($u) try { $x = [Uri]$u; if ($x.Scheme -eq 'https' -and ($x.Host -eq 'bridgerator.recondeck.com' -or $x.Host -eq $siteHost) -and -not $x.UserInfo) { Start-Process $x.AbsoluteUri } } catch {} }
77
+ $openAccount = { & $openUrl $account }
78
+ $g0 = [Drawing.Graphics]::FromHwnd([IntPtr]::Zero); $script:dpi = $g0.DpiX / 96.0; $g0.Dispose()
79
+ if ($env:BR_DPI) { $script:dpi = [double]$env:BR_DPI } # test harness: simulate scaling
80
+
81
+ # --- icon: the logo with a status dot (and a plan badge) ------------------------------------------
82
+ function New-TrayIcon([string]$state, [string]$badge) {
83
+ # Drawn at the tray's own icon size (16 at 100 %, 20 / 24 at 125 / 150 %): crisp, no downscaling.
84
+ $n = [Math]::Max(16, [Windows.Forms.SystemInformation]::SmallIconSize.Width)
85
+ $bm = New-LogoBitmap $n
86
+ $g = [Drawing.Graphics]::FromImage($bm); $g.SmoothingMode = 'AntiAlias'
87
+ $ring = [Drawing.ColorTranslator]::FromHtml('#0c0d0f')
88
+ $d = [Math]::Max(4, [Math]::Round($n * 0.34)); $rw = 1.5
89
+ $dot = { param($hex, $cx, $cy)
90
+ $g.FillEllipse((New-Object Drawing.SolidBrush $ring), ($cx - $d / 2 - $rw), ($cy - $d / 2 - $rw), ($d + 2 * $rw), ($d + 2 * $rw))
91
+ $g.FillEllipse((New-Object Drawing.SolidBrush ([Drawing.ColorTranslator]::FromHtml($hex))), ($cx - $d / 2), ($cy - $d / 2), $d, $d) }
92
+ # Status dot bottom-right with a 1.5 px ring (covers the logo's cyan dot, so they never clash).
93
+ & $dot $(switch ($state) { 'online' { '#32d74b' } 'paused' { '#ffb900' } default { '#8e9098' } }) ($n - $d / 2 - 0.5) ($n - $d / 2 - 0.5)
94
+ if ($badge) { & $dot $(if ($badge -eq 'red') { '#ff453a' } else { '#ffd60a' }) ($n - $d / 2 - 0.5) ($d / 2 + 0.5) }
95
+ $g.Dispose()
96
+ $h = $bm.GetHicon(); $bm.Dispose()
97
+ return @{ icon = [Drawing.Icon]::FromHandle($h); handle = $h }
98
+ }
99
+
100
+ # --- tray menu (as in the mockup: status line, open, queue, confirm, pause, account, help, quit) ----
101
+ # Labels in the PC's language (config.json language, sent with every state; V-3).
102
+ $TXT = @{
103
+ en = @{ open = 'Open Bridgerator'; queue = 'Task queue'; queueN = 'Task queue — {0} to do'; confirm = 'Confirm on this PC'; on = 'On'; off = 'Off'; pause = 'Pause'; p15 = 'For 15 minutes'; p60 = 'For 1 hour'; pInf = 'Until I resume'; resume = 'Resume'; account = 'Open account ↗'; help = 'Help'; quit = 'Quit Bridgerator'; online = 'Online'; paused = 'Paused'; offline = 'Offline'; renew = 'Renew Pro…'; upgrade = 'Upgrade to Pro…' }
104
+ ru = @{ open = 'Открыть Bridgerator'; queue = 'Очередь задач'; queueN = 'Очередь задач — {0} к выполнению'; confirm = 'Подтверждения на этом ПК'; on = 'Вкл.'; off = 'Выкл.'; pause = 'Пауза'; p15 = 'На 15 минут'; p60 = 'На 1 час'; pInf = 'Пока не возобновлю'; resume = 'Возобновить'; account = 'Открыть кабинет ↗'; help = 'Справка'; quit = 'Выйти из Bridgerator'; online = 'В сети'; paused = 'Пауза'; offline = 'Не в сети'; renew = 'Продлить Pro…'; upgrade = 'Перейти на Pro…' }
105
+ }
106
+ $S = @{ state = 'offline'; name = $env:COMPUTERNAME; todo = 0; confirm = 'off'; theme = 'system'; lang = 'en'; plan = $null }
107
+ function L([string]$k) { $t = $TXT[$(if ($S.lang -eq 'ru') { 'ru' } else { 'en' })]; [string]$t[$k] }
108
+ $ni = New-Object Windows.Forms.NotifyIcon
109
+ $script:iconKey = 'offline|'; $ti = New-TrayIcon 'offline' $null; $script:iconH = $ti.handle; $ni.Icon = $ti.icon; $ni.Text = 'Bridgerator'; $ni.Visible = $true
110
+ $menu = New-Object Windows.Forms.ContextMenuStrip
111
+ $menu.ShowImageMargin = $true; $menu.Font = New-Object Drawing.Font('Segoe UI', 9)
112
+ $script:dotImgs = @{}
113
+ function Get-Dot([string]$hex) { if (-not $script:dotImgs[$hex]) { $script:dotImgs[$hex] = New-Dot $hex }; $script:dotImgs[$hex] }
114
+ function New-Dot([string]$hex) { $b = New-Object Drawing.Bitmap 16, 16; $g = [Drawing.Graphics]::FromImage($b); $g.SmoothingMode = 'AntiAlias'; $g.FillEllipse((New-Object Drawing.SolidBrush ([Drawing.ColorTranslator]::FromHtml($hex))), 4, 4, 8, 8); $g.Dispose(); $b }
115
+ function Add-Item([string]$text, $onClick) { $i = New-Object Windows.Forms.ToolStripMenuItem $text; if ($onClick) { $i.Add_Click($onClick) }; [void]$menu.Items.Add($i); $i }
116
+ $miPlan = Add-Item 'Renew Pro…' { if ($S.plan) { & $openUrl $S.plan.url } }
117
+ $sepPlan = New-Object Windows.Forms.ToolStripSeparator; [void]$menu.Items.Add($sepPlan)
118
+ $miHead = Add-Item 'Offline' $null; $miHead.Enabled = $false
119
+ [void]$menu.Items.Add((New-Object Windows.Forms.ToolStripSeparator))
120
+ $miOpen = Add-Item 'Open Bridgerator' { Open-Window 'home' }; $miOpen.Font = New-Object Drawing.Font('Segoe UI', 9, [Drawing.FontStyle]::Bold)
121
+ $miQueue = Add-Item 'Task queue' { Open-Window 'queue' }
122
+ $miConfirm = Add-Item 'Confirm on this PC' $null
123
+ $miConfOn = New-Object Windows.Forms.ToolStripMenuItem 'On'; $miConfOn.Add_Click({ & $send @{ t = 'menu'; action = 'confirm'; on = $true } })
124
+ $miConfOff = New-Object Windows.Forms.ToolStripMenuItem 'Off'; $miConfOff.Add_Click({ & $send @{ t = 'menu'; action = 'confirm'; on = $false } })
125
+ [void]$miConfirm.DropDownItems.AddRange(@($miConfOn, $miConfOff))
126
+ $miPause = Add-Item 'Pause' $null
127
+ $p15 = New-Object Windows.Forms.ToolStripMenuItem 'For 15 minutes'; $p15.Add_Click({ & $send @{ t = 'menu'; action = 'pause'; minutes = 15 } })
128
+ $p60 = New-Object Windows.Forms.ToolStripMenuItem 'For 1 hour'; $p60.Add_Click({ & $send @{ t = 'menu'; action = 'pause'; minutes = 60 } })
129
+ $pInf = New-Object Windows.Forms.ToolStripMenuItem 'Until I resume'; $pInf.Add_Click({ & $send @{ t = 'menu'; action = 'pause' } })
130
+ [void]$miPause.DropDownItems.AddRange(@($p15, $p60, $pInf))
131
+ $miResume = Add-Item 'Resume' { & $send @{ t = 'menu'; action = 'resume' } }
132
+ [void]$menu.Items.Add((New-Object Windows.Forms.ToolStripSeparator))
133
+ $miAcc = Add-Item 'Open account ↗' $openAccount
134
+ $miHelp = Add-Item 'Help' { Open-Window 'help' }
135
+ [void]$menu.Items.Add((New-Object Windows.Forms.ToolStripSeparator))
136
+ $miQuit = Add-Item 'Quit Bridgerator' { & $send @{ t = 'menu'; action = 'quit' } }
137
+ $ni.ContextMenuStrip = $menu
138
+ # Every exit path (parent gone, stop, error) hides + disposes the icon, or a ghost stays in the tray until the mouse passes it.
139
+ # The window's WebView2 is disposed too: its msedgewebview2 processes end with it (V-8: an update left 12 behind).
140
+ function Remove-Tray {
141
+ try { $ni.Visible = $false; $ni.Dispose() } catch {}; if ($script:iconH) { [void][BrWin]::DestroyIcon($script:iconH); $script:iconH = $null }
142
+ try { if ($script:wv) { $script:wv.Dispose(); $script:wv = $null } } catch {}
143
+ try { if ($script:win -and -not $script:win.IsDisposed) { $W.forceClose = $true; $script:win.Close(); $script:win.Dispose() } } catch {}
144
+ }
145
+
146
+ # The bridge says when ChatGPT is working (awake); the PC is held awake only on mains power (a desktop
147
+ # reports Online or Unknown, never Offline). Display may still turn off; a sleep the person starts still works.
148
+ $script:awakeOn = $false; $script:acOn = $true
149
+ function Set-Awake($want) {
150
+ $ac = $true; try { $ac = [Windows.Forms.SystemInformation]::PowerStatus.PowerLineStatus -ne 'Offline' } catch {}
151
+ $on = [bool]$want -and $ac
152
+ if ($on -ne $script:awakeOn -or $ac -ne $script:acOn) {
153
+ [BrWin]::Awake($on); $script:awakeOn = $on; $script:acOn = $ac
154
+ & $send @{ t = 'power'; on = $on; ac = $ac }
155
+ }
156
+ }
157
+ function Set-TrayState($m) {
158
+ foreach ($k in 'state', 'name', 'todo', 'confirm', 'theme', 'lang') { if ($null -ne $m.$k -and "$($m.$k)" -ne '') { $S[$k] = $m.$k } }
159
+ if ($null -ne $m.awake) { try { Set-Awake $m.awake } catch { & $send @{ t = 'log'; msg = "keep awake: $_" } } }
160
+ $S.plan = $m.plan
161
+ $badge = if ($S.plan) { [string]$S.plan.badge } else { $null }
162
+ # Redrawn only when the state / badge changed (the bridge pushes the state every 5 s); the old HICON is destroyed.
163
+ if ($script:iconKey -ne "$($S.state)|$badge") {
164
+ $ti = New-TrayIcon $S.state $badge; $old = $ni.Icon; $oldH = $script:iconH
165
+ $ni.Icon = $ti.icon; $script:iconH = $ti.handle; $script:iconKey = "$($S.state)|$badge"
166
+ if ($old) { $old.Dispose() }; if ($oldH) { [void][BrWin]::DestroyIcon($oldH) }
167
+ }
168
+ $word = switch ($S.state) { 'online' { L 'online' } 'paused' { L 'paused' } default { L 'offline' } }
169
+ $t = "Bridgerator — $($word.ToLower()) ($($S.name))"; if ($t.Length -gt 63) { $t = $t.Substring(0, 63) }
170
+ if ($ni.Text -ne $t) { $ni.Text = $t }
171
+ $miHead.Text = "$word · $($S.name)"
172
+ $miHead.Image = Get-Dot $(switch ($S.state) { 'online' { '#32d74b' } 'paused' { '#ffb900' } default { '#8e9098' } })
173
+ $miQueue.Text = if ([int]$S.todo -gt 0) { (L 'queueN') -f $S.todo } else { L 'queue' }
174
+ $miConfirm.Text = "$(L 'confirm') — $(if ($S.confirm -eq 'on') { L 'on' } else { L 'off' })"
175
+ $miOpen.Text = L 'open'; $miConfOn.Text = L 'on'; $miConfOff.Text = L 'off'; $miPause.Text = L 'pause'; $p15.Text = L 'p15'; $p60.Text = L 'p60'; $pInf.Text = L 'pInf'
176
+ $miResume.Text = L 'resume'; $miAcc.Text = L 'account'; $miHelp.Text = L 'help'; $miQuit.Text = L 'quit'
177
+ $miConfOn.Checked = $S.confirm -eq 'on'; $miConfOff.Checked = $S.confirm -ne 'on'
178
+ $miPause.Visible = $S.state -ne 'paused'; $miResume.Visible = $S.state -eq 'paused'
179
+ $miPlan.Visible = [bool]$S.plan; $sepPlan.Visible = [bool]$S.plan
180
+ if ($S.plan) { $miPlan.Text = $(switch ([string]$S.plan.label) { 'Renew Pro…' { L 'renew' } 'Upgrade to Pro…' { L 'upgrade' } default { [string]$S.plan.label } }); $miPlan.Image = Get-Dot $(if ($badge -eq 'red') { '#ff453a' } else { '#ffd60a' }) }
181
+ }
182
+ Set-TrayState @{ state = 'offline' }
183
+
184
+ # --- the window (WebView2): 1040 x 900 (every page without scrollbars, owner 21.10; never larger than the screen), min 760 x 520 ---
185
+ # Created once, hidden, soon after the helper starts (WebView2 warm), shown only after the page has
186
+ # loaded; closing hides it — reopening is instant and never flashes.
187
+ $W = @{ win = $null; wv = $null; ready = $false; want = $null; shown = $false; t0 = $null; forceClose = $false }
188
+ $script:win = $null; $script:wv = $null
189
+ function Show-Win([string]$page) {
190
+ $f = $W.win
191
+ if ($W.wv.CoreWebView2) { $W.wv.CoreWebView2.PostWebMessageAsJson((@{ t = 'nav'; page = $page } | ConvertTo-Json -Compress)) }
192
+ $f.Show(); if ($f.WindowState -eq 'Minimized') { $f.WindowState = 'Normal' }
193
+ if (-not $W.shown) {
194
+ $W.shown = $true; [BrWin]::NoCaption($f.Handle)
195
+ # Physical pixels (the process is DPI-aware): never larger than the work area of the monitor, title bar always on screen.
196
+ $wa = [Windows.Forms.Screen]::FromHandle($f.Handle).WorkingArea
197
+ $f.MinimumSize = New-Object Drawing.Size([Math]::Min($f.MinimumSize.Width, $wa.Width), [Math]::Min($f.MinimumSize.Height, $wa.Height))
198
+ $f.ClientSize = New-Object Drawing.Size([int](1040 * $script:dpi), [int](900 * $script:dpi))
199
+ $f.Size = New-Object Drawing.Size([Math]::Min($f.Width, $wa.Width), [Math]::Min($f.Height, $wa.Height))
200
+ $f.Location = New-Object Drawing.Point(($wa.Left + [int](($wa.Width - $f.Width) / 2)), ($wa.Top + [int](($wa.Height - $f.Height) / 2)))
201
+ }
202
+ [void][BrWin]::ShowWindow($f.Handle, 5); [void][BrWin]::SetForegroundWindow($f.Handle); $f.Activate()
203
+ if ($W.t0) { & $send @{ t = 'log'; msg = "tray window shown in $([int]((Get-Date) - $W.t0).TotalMilliseconds) ms" }; $W.t0 = $null }
204
+ }
205
+ function Get-Dark { $th = [string]$S.theme; if ($th -eq 'dark') { return $true }; if ($th -eq 'light') { return $false }; try { return (Get-ItemProperty 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Themes\Personalize' -ErrorAction Stop).AppsUseLightTheme -eq 0 } catch { return $true } }
206
+ function Open-Window([string]$page = 'home', [switch]$Prewarm) {
207
+ if (-not $Prewarm) { $W.t0 = Get-Date }
208
+ $ui = if ($env:BR_UI_DIR) { $env:BR_UI_DIR } else { Join-Path $env:BR_PKG 'ui' }
209
+ $vendor = Join-Path $env:BR_PKG 'vendor\webview2'
210
+ if (-not (Test-Path (Join-Path $ui 'index.html'))) { if (-not $Prewarm) { & $openAccount }; return }
211
+ if ($W.win -and -not $W.win.IsDisposed) {
212
+ if ($Prewarm) { return }
213
+ if ($W.ready) { Show-Win $page } else { $W.want = $page }
214
+ return
215
+ }
216
+ try {
217
+ $arch = if ($env:PROCESSOR_ARCHITECTURE -eq 'ARM64') { 'arm64' } else { 'x64' }
218
+ $env:PATH = (Join-Path $vendor $arch) + ';' + $env:PATH # WebView2Loader.dll
219
+ Add-Type -Path (Join-Path $vendor 'Microsoft.Web.WebView2.Core.dll'), (Join-Path $vendor 'Microsoft.Web.WebView2.WinForms.dll')
220
+ [void][Microsoft.Web.WebView2.Core.CoreWebView2Environment]::GetAvailableBrowserVersionString()
221
+ } catch { & $send @{ t = 'log'; msg = "tray: WebView2 not available ($_)$(if ($Prewarm) { ' - prewarm skipped, tried again when the window is opened' } else { ', opening the account page' })" }; if (-not $Prewarm) { & $openAccount }; return }
222
+ $dark = Get-Dark
223
+ $bg = [Drawing.ColorTranslator]::FromHtml($(if ($dark) { '#141518' } else { '#f6f7f9' }))
224
+ $k = $script:dpi
225
+ $f = New-Object Windows.Forms.Form
226
+ # Sizable frame without a caption: the page draws its own title bar (drag region via app-region).
227
+ $f.FormBorderStyle = 'Sizable'; $f.ControlBox = $false; $f.Text = ''; $f.ShowIcon = $true
228
+ $f.Icon = New-Object Drawing.Icon (Join-Path $env:BR_PKG 'assets\bridgerator.ico'); $f.StartPosition = 'CenterScreen'; $f.AutoScaleMode = 'None'; $f.BackColor = $bg
229
+ $f.ClientSize = New-Object Drawing.Size([int](1040 * $k), [int](900 * $k))
230
+ $f.MinimumSize = New-Object Drawing.Size([int](760 * $k), [int](520 * $k))
231
+ $wv = New-Object Microsoft.Web.WebView2.WinForms.WebView2; $wv.Dock = 'Fill'; $wv.DefaultBackgroundColor = $bg
232
+ $cp = New-Object Microsoft.Web.WebView2.WinForms.CoreWebView2CreationProperties
233
+ $cp.UserDataFolder = Join-Path $env:LOCALAPPDATA 'Bridgerator\webview'; $wv.CreationProperties = $cp
234
+ if ($env:BR_ZOOM) { $wv.ZoomFactor = [double]$env:BR_ZOOM }
235
+ $f.Controls.Add($wv)
236
+ $post = { param($o) if ($wv.CoreWebView2) { $wv.CoreWebView2.PostWebMessageAsJson(($o | ConvertTo-Json -Compress -Depth 4)) } }.GetNewClosure()
237
+ [BrWin]::Post = $post
238
+ $wv.Add_CoreWebView2InitializationCompleted({
239
+ param($s, $e)
240
+ if (-not $e.IsSuccess) {
241
+ # Close() is a UserClosing (hidden by the handler below): force it (the flag lives in $W: each .GetNewClosure() handler has its own $script: scope); only an explicit click falls back to the browser.
242
+ $wanted = [bool]$W.want; $W.forceClose = $true; try { $f.Close() } finally { $W.forceClose = $false }
243
+ if ($wanted) { & $openAccount }
244
+ return
245
+ }
246
+ $c = $wv.CoreWebView2
247
+ $c.Settings.AreDevToolsEnabled = [bool]$env:BR_DEVTOOLS; $c.Settings.IsStatusBarEnabled = $false; $c.Settings.AreDefaultContextMenusEnabled = [bool]$env:BR_DEVTOOLS
248
+ $c.Settings.IsZoomControlEnabled = $false
249
+ $c.SetVirtualHostNameToFolderMapping('bridgerator.local', $ui, 'Deny')
250
+ # Only our own page: anything else opens in the default browser (our site only) or is refused.
251
+ $c.Add_NavigationStarting({ param($s2, $n) if ($n.Uri -notlike 'https://bridgerator.local/*') { $n.Cancel = $true; & $openUrl $n.Uri } }.GetNewClosure())
252
+ $c.Add_NewWindowRequested({ param($s2, $n) $n.Handled = $true; & $openUrl $n.Uri }.GetNewClosure())
253
+ $c.Add_WebMessageReceived({
254
+ param($s2, $m)
255
+ try {
256
+ # Only our own page may talk to the helper.
257
+ if ([string]$m.Source -notlike 'https://bridgerator.local/*') { return }
258
+ $f = [BrWin]::MainForm; $post = [BrWin]::Post
259
+ $msg = $m.WebMessageAsJson | ConvertFrom-Json
260
+ $paths = @(); try { foreach ($o in $m.AdditionalObjects) { if ($o.Path) { $paths += [string]$o.Path } } } catch {}
261
+ switch ($msg.method) {
262
+ 'openUrl' { & $openUrl ([string]$msg.params.url); & $post @{ id = $msg.id; result = $true } }
263
+ 'minimize' { $f.WindowState = 'Minimized'; & $post @{ id = $msg.id; result = $true } }
264
+ 'drag' { [BrWin]::Drag($f.Handle); & $post @{ id = $msg.id; result = $true } }
265
+ 'hide' { $f.Hide(); & $post @{ id = $msg.id; result = $true } }
266
+ 'droppedPaths' { & $post @{ id = $msg.id; result = @($paths) } }
267
+ 'pickFolder' {
268
+ $d = New-Object Windows.Forms.FolderBrowserDialog
269
+ $r = if ($d.ShowDialog($f) -eq 'OK') { $d.SelectedPath } else { $null }
270
+ & $post @{ id = $msg.id; result = $r }
271
+ }
272
+ 'pickFiles' {
273
+ $d = New-Object Windows.Forms.OpenFileDialog; $d.Multiselect = $true
274
+ $r = if ($d.ShowDialog($f) -eq 'OK') { @($d.FileNames) } else { @() }
275
+ & $post @{ id = $msg.id; result = $r }
276
+ }
277
+ default { & $send @{ t = 'rpc'; id = [string]$msg.id; method = [string]$msg.method; params = $msg.params; paths = $paths } }
278
+ }
279
+ } catch { & $send @{ t = 'log'; msg = "tray window: $_" } }
280
+ }.GetNewClosure())
281
+ # First page loaded: only now the window may appear (no white / black flash).
282
+ $c.Add_NavigationCompleted({ if (-not $W.ready) { $W.ready = $true; if ($W.want) { $p0 = $W.want; $W.want = $null; Show-Win $p0 } } }.GetNewClosure())
283
+ $c.Navigate("https://bridgerator.local/index.html#$page")
284
+ }.GetNewClosure())
285
+ $f.Add_HandleCreated({ [void][BrWin]::SetWindowText($f.Handle, 'Bridgerator'); [BrWin]::MainForm = $f; [BrWin]::Frame = New-Object BrFrame $f.Handle; [BrWin]::NoCaption($f.Handle); if ($dark) { $v = 1; [void][BrWin]::DwmSetWindowAttribute($f.Handle, 20, [ref]$v, 4) } }.GetNewClosure())
286
+
287
+ # Closing hides to the tray (the X in the page, Alt+F4); Quit is in the tray menu.
288
+ $f.Add_FormClosing({ param($s, $e) if ($e.CloseReason -eq 'UserClosing' -and -not $W.forceClose) { $e.Cancel = $true; $f.Hide() } }.GetNewClosure())
289
+ $f.Add_FormClosed({ $W.win = $null; $W.wv = $null; $W.ready = $false; $W.shown = $false; $script:win = $null; $script:wv = $null })
290
+ $W.win = $f; $W.wv = $wv; $W.ready = $false; $W.shown = $false; $W.want = $(if ($Prewarm) { $null } else { $page })
291
+ $script:win = $f; $script:wv = $wv
292
+ # Handles without showing; WebView2 starts loading the page in the background.
293
+ [void]$f.Handle; [void]$wv.Handle
294
+ [void]$wv.EnsureCoreWebView2Async($null)
295
+ }
296
+ $ni.Add_DoubleClick({ try { Open-Window 'home' } catch { & $send @{ t = 'log'; msg = "tray: $_" } } })
297
+
298
+ # --- questions (toast; fallback window when our notifications are off) ---------------------------
299
+ $pending = @{} # id -> @{ w = BrWait; tag; deadline }
300
+ # popupStyle = toast but the own window appeared (V-4): the reason goes to bridge.log once per reason
301
+ # (notifications for Bridgerator / all apps off = Setting not Enabled, the toast failed with its HRESULT, ...).
302
+ $script:fallbackWhy = @{}
303
+ function Write-FallbackWhy([string]$why) { if (-not $script:fallbackWhy[$why]) { $script:fallbackWhy[$why] = $true; & $send @{ t = 'log'; msg = "question shown in the Bridgerator window instead of a Windows notification: $why" } } }
304
+ function Start-Ask($m) {
305
+ $specs = @(); foreach ($b in $m.buttons) { $specs += ,@([string]$b[0], [string]$b[1], [string]$b[2]) }
306
+ $q = @{ specs = $specs; head = [string]$m.head; detail = [string]$m.detail; attr = [string]$m.attr; host = [string]$m.host; auto = [string]$m.auto; secs = [int]$m.secs; tag = "t$([string]$m.id)"; theme = [string]$m.theme; icon = (Join-Path $env:BR_PKG 'assets\icon-64.png'); silent = [bool]$m.silent; lang = [string]$m.lang }
307
+ if ($q.tag.Length -gt 60) { $q.tag = $q.tag.Substring(0, 60) }
308
+ $w = New-Object BrWait; $w.Id = [string]$m.id; $w.Report = $true; $w.Keys = [string[]]@($specs | ForEach-Object { $_[0] })
309
+ try {
310
+ if ($m.style -eq 'popup') { throw 'compact pop-up chosen' }
311
+ Show-AskToast $q $w
312
+ $pending[$w.Id] = @{ w = $w; q = $q; deadline = (Get-Date).AddSeconds($q.secs) }
313
+ } catch { if ("$_" -ne 'compact pop-up chosen') { Write-FallbackWhy "$_" }; Show-AskWindow $q $w.Id $w.Keys }
314
+ }
315
+ # Our notifications are off (or the toast failed): the small window, answer reported when it closes.
316
+ function Show-AskWindow($q, $id, $keys) {
317
+ $w = New-Object BrWait; $w.Id = $id; $w.Report = $true; $w.Keys = $keys
318
+ try {
319
+ $f = New-AskForm $q
320
+ $f.Add_FormClosed({ [void]$w.Finish($f.Tag.pick) }.GetNewClosure())
321
+ $f.Show(); [void][BrWin]::ShowWindow($f.Handle, 4) # SW_SHOWNOACTIVATE: no focus stealing
322
+ } catch { [void]$w.Finish('deny') }
323
+ }
324
+
325
+ # --- main loop on the UI thread ----------------------------------------------------------------
326
+ [BrIn]::Start()
327
+ & $send @{ t = 'ready' }
328
+ $warm = New-Object Windows.Forms.Timer; $warm.Interval = 200
329
+ $warm.Add_Tick({ $warm.Stop(); try { Open-Window 'home' -Prewarm } catch { & $send @{ t = 'log'; msg = "tray prewarm: $_" } } })
330
+ $warm.Start()
331
+ $script:lastParentCheck = Get-Date
332
+ $tm = New-Object Windows.Forms.Timer; $tm.Interval = 100
333
+ $tm.Add_Tick({
334
+ $line = $null
335
+ while ([BrIn]::Lines.TryDequeue([ref]$line)) {
336
+ try {
337
+ $m = $line | ConvertFrom-Json
338
+ switch ($m.t) {
339
+ 'state' { Set-TrayState $m }
340
+ 'ask' { Start-Ask $m }
341
+ 'rpc-result' { if ($script:wv -and $script:wv.CoreWebView2) { $script:wv.CoreWebView2.PostWebMessageAsJson($line) } }
342
+ 'exit' { Remove-Tray; [Windows.Forms.Application]::Exit() }
343
+ 'open' { Open-Window $(if ($m.page) { [string]$m.page } else { 'home' }) }
344
+ }
345
+ } catch { & $send @{ t = 'log'; msg = "tray: $_ $($_.InvocationInfo.PositionMessage)" } }
346
+ }
347
+ foreach ($id in @($pending.Keys)) {
348
+ $p = $pending[$id]
349
+ if ($p.w.IsOver) { $pending.Remove($id); if ($p.w.Result -eq 'failed') { Write-FallbackWhy "Windows could not show the notification ($($p.w.FailCode))"; Show-AskWindow $p.q $id $p.w.Keys }; continue }
350
+ if ((Get-Date) -gt $p.deadline) { Remove-AskToast $p.q.tag; [void]$p.w.Finish('deny'); $pending.Remove($id) }
351
+ }
352
+ if ([BrIn]::Closed -or ((Get-Date) - $script:lastParentCheck).TotalSeconds -ge 2) {
353
+ $script:lastParentCheck = Get-Date
354
+ $alive = $false; try { $alive = -not [BrIn]::Closed -and [bool](Get-Process -Id $parent -ErrorAction Stop) } catch {}
355
+ if (-not $alive) { Remove-Tray; [Windows.Forms.Application]::Exit() }
356
+ }
357
+ })
358
+ $tm.Start()
359
+ try { [Windows.Forms.Application]::Run() } finally { Remove-Tray }