gemini-computer-use 0.2.4 → 0.4.2

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.
@@ -0,0 +1,1882 @@
1
+ # ==============================================================================
2
+ # Antigravity Mesh - Windows Visual Installer (WinForms wizard)
3
+ # ==============================================================================
4
+ #
5
+ # WHY THIS FILE IS PURE ASCII
6
+ #
7
+ # Windows PowerShell 5.1 decodes a script that has no UTF-8 BOM using the ANSI
8
+ # code page. Russian text then turns into characters the parser reads as string
9
+ # delimiters and the whole file fails to parse - exactly why install.ps1 carries
10
+ # a BOM and why docs/WINDOWS_TESTING.md tells you not to normalise it. Keeping
11
+ # this source ASCII-only makes the wizard parse correctly no matter how the file
12
+ # was saved or transferred, and it moves every user-visible string into
13
+ # install-gui.strings.json, which is read explicitly as UTF-8. That file is also
14
+ # what makes the language switch a data change instead of a code change.
15
+ #
16
+ # WHY IT CALLS install.ps1 INSTEAD OF REIMPLEMENTING IT
17
+ #
18
+ # The wizard collects the same options as the console installer and then runs
19
+ # install.ps1 with them. Node registration, the fail-closed check on an empty
20
+ # token, the autostart entry and the shared-domain URL contract therefore stay in
21
+ # one place, and the visual and console installers cannot drift apart.
22
+ #
23
+ # SSH mode is the one exception: install.ps1 has no remote branch, so the wizard
24
+ # drives ssh.exe/scp.exe the way install.sh --ssh does and runs install.sh on the
25
+ # remote host, which is where the real work happens.
26
+ #
27
+ # Usage:
28
+ # powershell -NoProfile -ExecutionPolicy Bypass -Sta -File install-gui.ps1
29
+ # install-gui.cmd (double-click launcher)
30
+ # install-gui.ps1 -Lang ru (start in Russian)
31
+ # install-gui.ps1 -SelfTest (headless self-check, no window)
32
+ # ==============================================================================
33
+
34
+ [CmdletBinding()]
35
+ param(
36
+ # Start in this language instead of following the OS UI language.
37
+ [ValidateSet('en', 'ru')]
38
+ [string]$Lang = '',
39
+
40
+ # Build every page in every language, exercise the process pipeline and the
41
+ # URL parser, then exit. Used by tests/test_installer_gui.py.
42
+ [switch]$SelfTest,
43
+
44
+ # Install without opening a window: the same steps the wizard runs, their
45
+ # output on stdout, and the resulting MCP URL as MESH_URL=<url>. This is what
46
+ # makes the compiled installer verifiable end to end.
47
+ #
48
+ # Every name here is prefixed on purpose. At script scope "$script:Mode" and a
49
+ # parameter "$Mode" are the SAME variable, so the initialiser for the wizard's
50
+ # state would overwrite whatever the caller passed - the bug that once made
51
+ # -Lang do nothing at all.
52
+ [switch]$RunInstall,
53
+ [ValidateSet('quick', 'custom', 'ssh')]
54
+ [string]$InstallMode = 'quick',
55
+ [string]$InstallUser = '',
56
+ [string]$InstallGateway = '',
57
+ [string]$InstallToken = '',
58
+ [string]$InstallSshTarget = '',
59
+ [int]$InstallSshPort = 22
60
+ )
61
+
62
+ $ErrorActionPreference = 'Continue'
63
+
64
+ try {
65
+ [Console]::OutputEncoding = [System.Text.Encoding]::UTF8
66
+ $OutputEncoding = [System.Text.Encoding]::UTF8
67
+ } catch { }
68
+
69
+ Add-Type -AssemblyName System.Windows.Forms
70
+ Add-Type -AssemblyName System.Drawing
71
+
72
+ # ------------------------------------------------------------------------------
73
+ # Paths and constants
74
+ # ------------------------------------------------------------------------------
75
+
76
+ $ScriptPath = $null
77
+ if ($MyInvocation.MyCommand -and $MyInvocation.MyCommand.Path) {
78
+ $ScriptPath = $MyInvocation.MyCommand.Path
79
+ }
80
+ $ScriptDir = $null
81
+ if ($ScriptPath) { $ScriptDir = Split-Path -Parent $ScriptPath }
82
+ if (-not $ScriptDir) { $ScriptDir = (Get-Location).Path }
83
+ if (-not $ScriptPath) { $ScriptPath = Join-Path $ScriptDir 'install-gui.ps1' }
84
+
85
+ $StringsFile = Join-Path $ScriptDir 'install-gui.strings.json'
86
+ $ConsoleInstaller = Join-Path $ScriptDir 'install.ps1'
87
+ $ShellInstaller = Join-Path $ScriptDir 'install.sh'
88
+ $CoreDir = Join-Path $ScriptDir 'core'
89
+ $SkillsDir = Join-Path $ScriptDir 'skills'
90
+
91
+ $ConfigDir = Join-Path $env:USERPROFILE '.config\antigravity-mesh'
92
+ $AgentEnvFile = Join-Path $ConfigDir 'agent.env'
93
+ $AgentLogFile = Join-Path $ConfigDir 'agent.log'
94
+ $StartupDir = [Environment]::GetFolderPath('Startup')
95
+ $AutostartFile = Join-Path $StartupDir 'antigravity-agent.vbs'
96
+
97
+ $GeminiAppsUrl = 'https://gemini.google.com/spark/apps'
98
+
99
+ # The canonical shared-domain URL: one domain for every node, the node selected
100
+ # by ?user=. This pattern only reads back the URL install.ps1 printed; it never
101
+ # invents one. The gateway's own sse_url has exactly this shape (see
102
+ # tests/test_gateway_url_contract.py), which is what makes the agent.env
103
+ # fallback below faithful rather than a guess.
104
+ $UrlPattern = 'https?://[^\s|"'']+/sse[^\s|"'']*'
105
+
106
+ # ------------------------------------------------------------------------------
107
+ # Tolerant readers
108
+ #
109
+ # One session sees three encodings on Windows: PowerShell and cmd write the
110
+ # console OEM code page, most programs write ANSI, and python/git/curl write
111
+ # UTF-8. Decoding with the wrong one produces mojibake, so try UTF-8 strictly
112
+ # first and fall back rather than trusting the locale. Same rule as
113
+ # core/mcp_tools.py:_decode_output.
114
+ # ------------------------------------------------------------------------------
115
+
116
+ function Read-TextFileTolerant {
117
+ param([string]$Path)
118
+ if (-not $Path -or -not (Test-Path -LiteralPath $Path)) { return '' }
119
+ try { $bytes = [System.IO.File]::ReadAllBytes($Path) } catch { return '' }
120
+ if ($bytes.Length -eq 0) { return '' }
121
+
122
+ $text = $null
123
+ $strict = New-Object System.Text.UTF8Encoding($false, $true)
124
+ try {
125
+ $text = $strict.GetString($bytes)
126
+ } catch {
127
+ $codePage = 0
128
+ try { $codePage = [Console]::OutputEncoding.CodePage } catch { }
129
+ if ($codePage -le 0) { $codePage = 866 }
130
+ try { $text = [System.Text.Encoding]::GetEncoding($codePage).GetString($bytes) }
131
+ catch { $text = [System.Text.Encoding]::Default.GetString($bytes) }
132
+ }
133
+ return ($text -replace "`r`n", "`n")
134
+ }
135
+
136
+ function Read-Utf8Json {
137
+ param([string]$Path)
138
+ $bytes = [System.IO.File]::ReadAllBytes($Path)
139
+ if ($bytes.Length -eq 0) { throw 'file is empty' }
140
+ $offset = 0
141
+ if ($bytes.Length -ge 3 -and $bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF) {
142
+ $offset = 3
143
+ }
144
+ $strict = New-Object System.Text.UTF8Encoding($false, $true)
145
+ try {
146
+ $text = $strict.GetString($bytes, $offset, $bytes.Length - $offset)
147
+ } catch {
148
+ # Not valid UTF-8: an editor saved it as ANSI. Russian still has a chance
149
+ # of being right under the system default code page.
150
+ $text = [System.Text.Encoding]::Default.GetString($bytes, $offset, $bytes.Length - $offset)
151
+ }
152
+ return ($text | ConvertFrom-Json)
153
+ }
154
+
155
+
156
+ # ------------------------------------------------------------------------------
157
+ # Localisation
158
+ # ------------------------------------------------------------------------------
159
+
160
+ $script:Strings = $null
161
+ $script:UiLang = 'en'
162
+
163
+ function Initialize-Strings {
164
+ if (-not (Test-Path -LiteralPath $StringsFile)) {
165
+ throw ('__STRINGS_MISSING__' + $StringsFile)
166
+ }
167
+ try {
168
+ $script:Strings = Read-Utf8Json -Path $StringsFile
169
+ } catch {
170
+ throw ('__STRINGS_INVALID__' + $_.Exception.Message)
171
+ }
172
+ foreach ($code in @('en', 'ru')) {
173
+ if (-not ($script:Strings.PSObject.Properties.Name -contains $code)) {
174
+ throw ('__LANG_MISSING__' + $code)
175
+ }
176
+ }
177
+ }
178
+
179
+ function Get-UiText {
180
+ param([string]$Key)
181
+ $table = $script:Strings.($script:UiLang)
182
+ if ($null -ne $table -and ($table.PSObject.Properties.Name -contains $Key)) {
183
+ return [string]$table.$Key
184
+ }
185
+ $fallback = $script:Strings.en
186
+ if ($null -ne $fallback -and ($fallback.PSObject.Properties.Name -contains $Key)) {
187
+ return [string]$fallback.$Key
188
+ }
189
+ return $Key
190
+ }
191
+
192
+ # get text and apply {0}-style formatting in one step
193
+ function Format-UiText {
194
+ param([string]$Key, [object[]]$Values)
195
+ $template = Get-UiText -Key $Key
196
+ if ($null -eq $Values -or $Values.Count -eq 0) { return $template }
197
+ try { return ($template -f $Values) } catch { return $template }
198
+ }
199
+
200
+ # ------------------------------------------------------------------------------
201
+ # Which language to start in
202
+ #
203
+ # CultureInfo.CurrentUICulture is NOT a reliable answer on Windows. On the
204
+ # machine this was developed on, Windows is installed and used in Russian
205
+ # (PreferredUILanguages = ru-RU, InstalledUICulture = ru-RU, CurrentCulture =
206
+ # ru-RU, Get-WinSystemLocale = ru-RU) while the process reports
207
+ # CurrentUICulture = en-US, so a check against it alone started the wizard in
208
+ # English. The user's own language list is the signal that matches what they
209
+ # actually see, so it is read first and the process culture is only a fallback.
210
+ # ------------------------------------------------------------------------------
211
+
212
+ # One registry value holding a ';'-separated language list, split into entries.
213
+ function Get-RegistryLanguageList {
214
+ param([string]$Path, [string]$Name)
215
+ $values = New-Object System.Collections.ArrayList
216
+ try {
217
+ $raw = (Get-ItemProperty -LiteralPath $Path -Name $Name -ErrorAction Stop).$Name
218
+ } catch {
219
+ return $values.ToArray()
220
+ }
221
+ foreach ($entry in @($raw)) {
222
+ foreach ($part in ("$entry" -split ';')) {
223
+ $trimmed = $part.Trim()
224
+ if ($trimmed) { [void]$values.Add($trimmed) }
225
+ }
226
+ }
227
+ return $values.ToArray()
228
+ }
229
+
230
+ # Every language signal, most authoritative first.
231
+ function Get-PreferredLanguageCodes {
232
+ $codes = New-Object System.Collections.ArrayList
233
+
234
+ # 1. The Windows display language.
235
+ foreach ($value in @(Get-RegistryLanguageList 'HKCU:\Control Panel\Desktop' 'PreferredUILanguages')) {
236
+ [void]$codes.Add([string]$value)
237
+ }
238
+ # 2. The ordered list of preferred languages from Settings.
239
+ foreach ($value in @(Get-RegistryLanguageList 'HKCU:\Control Panel\International\User Profile' 'Languages')) {
240
+ [void]$codes.Add([string]$value)
241
+ }
242
+ # 3. The International module, only when the registry said nothing.
243
+ if ($codes.Count -eq 0 -and (Get-Command 'Get-WinUserLanguageList' -ErrorAction SilentlyContinue)) {
244
+ try {
245
+ foreach ($item in (Get-WinUserLanguageList -ErrorAction Stop)) {
246
+ if ($item.LanguageTag) { [void]$codes.Add([string]$item.LanguageTag) }
247
+ }
248
+ } catch { }
249
+ }
250
+ # 4. The process UI language, the OS install language, then the format.
251
+ foreach ($culture in @(
252
+ [System.Globalization.CultureInfo]::CurrentUICulture,
253
+ [System.Globalization.CultureInfo]::InstalledUICulture,
254
+ [System.Globalization.CultureInfo]::CurrentCulture)) {
255
+ if ($culture -and $culture.Name) { [void]$codes.Add([string]$culture.Name) }
256
+ }
257
+ return $codes.ToArray()
258
+ }
259
+
260
+ # First code that this wizard can actually speak, else English.
261
+ function Select-Language {
262
+ param([string[]]$Codes, [string[]]$Supported = @('en', 'ru'))
263
+ foreach ($code in @($Codes)) {
264
+ $short = ("$code" -split '-')[0].Trim().ToLower()
265
+ if ($short -and ($Supported -contains $short)) { return $short }
266
+ }
267
+ return 'en'
268
+ }
269
+
270
+ function Get-DefaultLanguage {
271
+ return (Select-Language -Codes @(Get-PreferredLanguageCodes))
272
+ }
273
+
274
+ # ------------------------------------------------------------------------------
275
+ # Small helpers
276
+ # ------------------------------------------------------------------------------
277
+
278
+ function Quote-Arg {
279
+ param([string]$Value)
280
+ return '"' + ($Value -replace '"', '\"') + '"'
281
+ }
282
+
283
+ function Get-DeviceInfo {
284
+ $rawHost = "$env:COMPUTERNAME"
285
+ $hostName = ($rawHost.ToLower() -replace '[^a-z0-9_-]', '')
286
+ $isLaptop = $false
287
+ try {
288
+ $isLaptop = [bool](Get-CimInstance -ClassName Win32_Battery -ErrorAction SilentlyContinue)
289
+ } catch { }
290
+ return @{
291
+ Hostname = $hostName
292
+ Raw = $rawHost
293
+ IsLaptop = $isLaptop
294
+ Os = 'Windows ' + [System.Environment]::OSVersion.Version.ToString()
295
+ }
296
+ }
297
+
298
+ function Set-UrlClipboard {
299
+ param([string]$Text)
300
+ try { Set-Clipboard -Value $Text -ErrorAction Stop; return $true } catch { }
301
+ try { [System.Windows.Forms.Clipboard]::SetText($Text); return $true } catch { }
302
+ try { $Text | clip.exe 2>$null; return $true } catch { }
303
+ return $false
304
+ }
305
+
306
+ function Start-Url {
307
+ param([string]$Url)
308
+ try { Start-Process $Url; return $true } catch { }
309
+ try { [System.Diagnostics.Process]::Start($Url) | Out-Null; return $true } catch { }
310
+ return $false
311
+ }
312
+
313
+ function Open-Folder {
314
+ param([string]$Path)
315
+ if (-not (Test-Path -LiteralPath $Path)) { return $false }
316
+ try { Start-Process explorer.exe -ArgumentList (Quote-Arg $Path); return $true } catch { }
317
+ return $false
318
+ }
319
+
320
+ # A domain value is "configured" when it is non-empty, carries no whitespace and
321
+ # is not the deployment placeholder. Both install.ps1's English "NOT CONFIGURED"
322
+ # and its Russian equivalent contain a space, so this test does not depend on the
323
+ # language the dry run was printed in.
324
+ function Test-DomainConfigured {
325
+ param([string]$Value)
326
+ $text = "$Value".Trim()
327
+ if (-not $text) { return $false }
328
+ if ($text -match '\s') { return $false }
329
+ if ($text -eq '__MESH_DOMAIN__') { return $false }
330
+ return $true
331
+ }
332
+
333
+ # ------------------------------------------------------------------------------
334
+ # Process pipeline
335
+ #
336
+ # A queue of external commands run one after another, with stdout/stderr
337
+ # redirected straight to files and a WinForms timer polling them. The child
338
+ # writes to files itself, so there is nothing to deadlock on and no second
339
+ # runspace to marshal across - both of which a BeginOutputReadLine handler would
340
+ # have needed under PowerShell 5.1.
341
+ # ------------------------------------------------------------------------------
342
+
343
+ $script:Headless = $false
344
+ $script:Timer = $null
345
+ $script:ScratchDir = $null
346
+
347
+ $script:Pipe = @{
348
+ Steps = @()
349
+ Index = -1
350
+ Proc = $null
351
+ OutFile = ''
352
+ ErrFile = ''
353
+ Output = ''
354
+ Failed = $false
355
+ FailExit = 0
356
+ Running = $false
357
+ OnFinish = $null
358
+ LogBox = $null
359
+ StatusLabel = $null
360
+ }
361
+
362
+ function New-PipelineStep {
363
+ param(
364
+ [string]$File,
365
+ [string]$Arguments,
366
+ [string]$Label,
367
+ [switch]$IgnoreExit
368
+ )
369
+ return @{
370
+ File = $File
371
+ Arguments = $Arguments
372
+ Label = $Label
373
+ IgnoreExit = [bool]$IgnoreExit
374
+ }
375
+ }
376
+
377
+ # Where the pipeline keeps the child's stdout/stderr and the self-test keeps its
378
+ # probes. %TEMP% is the obvious choice but not always a writable one: on a locked
379
+ # down machine, and inside a sandboxed folder, the write is refused and the child
380
+ # then produces nothing at all - which used to be reported as "no gateway domain
381
+ # configured". Each candidate is probed with a real write before it is used.
382
+ function Get-ScratchDirectory {
383
+ if ($script:ScratchDir) { return $script:ScratchDir }
384
+
385
+ $candidates = New-Object System.Collections.ArrayList
386
+ if ($env:TEMP) { [void]$candidates.Add($env:TEMP) }
387
+ [void]$candidates.Add((Join-Path $ScriptDir 'runtime'))
388
+ if ($env:LOCALAPPDATA) {
389
+ [void]$candidates.Add((Join-Path $env:LOCALAPPDATA 'AntigravityMesh\runtime'))
390
+ }
391
+
392
+ foreach ($candidate in $candidates) {
393
+ try {
394
+ if (-not (Test-Path -LiteralPath $candidate)) {
395
+ [void](New-Item -ItemType Directory -Path $candidate -Force -ErrorAction Stop)
396
+ }
397
+ $probe = Join-Path $candidate ('write-probe-' + [Guid]::NewGuid().ToString('N') + '.tmp')
398
+ Set-Content -LiteralPath $probe -Value '' -Encoding ASCII -ErrorAction Stop
399
+ Remove-Item -LiteralPath $probe -Force -ErrorAction Stop
400
+ $script:ScratchDir = $candidate
401
+ return $candidate
402
+ } catch {
403
+ continue
404
+ }
405
+ }
406
+
407
+ # Nothing worked; return the first choice so the failure names a real path.
408
+ $script:ScratchDir = $candidates[0]
409
+ return $script:ScratchDir
410
+ }
411
+
412
+ function Reset-Pipeline {
413
+ param(
414
+ [object[]]$Steps,
415
+ [scriptblock]$OnFinish,
416
+ $LogBox = $null,
417
+ $StatusLabel = $null
418
+ )
419
+ $script:Pipe.Steps = @($Steps)
420
+ $script:Pipe.Index = -1
421
+ $script:Pipe.Output = ''
422
+ $script:Pipe.Failed = $false
423
+ $script:Pipe.FailExit = 0
424
+ $script:Pipe.Running = $true
425
+ $script:Pipe.OnFinish = $OnFinish
426
+ $script:Pipe.LogBox = $LogBox
427
+ $script:Pipe.StatusLabel = $StatusLabel
428
+ $script:Pipe.Proc = $null
429
+
430
+ $stamp = [Guid]::NewGuid().ToString('N')
431
+ $scratch = Get-ScratchDirectory
432
+ $script:Pipe.OutFile = Join-Path $scratch ('mesh-gui-out-' + $stamp + '.log')
433
+ $script:Pipe.ErrFile = Join-Path $scratch ('mesh-gui-err-' + $stamp + '.log')
434
+ try {
435
+ Set-Content -LiteralPath $script:Pipe.OutFile -Value '' -Encoding ASCII
436
+ Set-Content -LiteralPath $script:Pipe.ErrFile -Value '' -Encoding ASCII
437
+ } catch { }
438
+
439
+ Start-NextPipelineStep
440
+ }
441
+
442
+ function Start-NextPipelineStep {
443
+ $script:Pipe.Index++
444
+ if ($script:Pipe.Index -ge $script:Pipe.Steps.Count) {
445
+ Complete-Pipeline
446
+ return
447
+ }
448
+ $step = $script:Pipe.Steps[$script:Pipe.Index]
449
+ if ($script:Pipe.StatusLabel) {
450
+ try { $script:Pipe.StatusLabel.Text = $step.Label } catch { }
451
+ }
452
+ $script:Pipe.Output += ('--- ' + $step.Label + "`n")
453
+
454
+ # cmd.exe applies the redirection, so the child writes straight to the files.
455
+ # Start-Process -RedirectStandardOutput captures output just as well, but the
456
+ # Process object it hands back has already lost the handle: its ExitCode comes
457
+ # back empty (measured on Windows PowerShell 5.1), and an installer that
458
+ # cannot tell success from failure is worse than useless. Creating the Process
459
+ # through ProcessStartInfo keeps ExitCode readable, and CreateNoWindow keeps
460
+ # the console from flashing.
461
+ $inner = (Quote-Arg $step.File) + ' ' + $step.Arguments
462
+ $cmdLine = '/c "' + $inner + ' > "' + $script:Pipe.OutFile + '" 2> "' + $script:Pipe.ErrFile + '""'
463
+
464
+ $proc = $null
465
+ try {
466
+ $startInfo = New-Object System.Diagnostics.ProcessStartInfo
467
+ $startInfo.FileName = $env:ComSpec
468
+ $startInfo.Arguments = $cmdLine
469
+ $startInfo.UseShellExecute = $false
470
+ $startInfo.CreateNoWindow = $true
471
+ $proc = [System.Diagnostics.Process]::Start($startInfo)
472
+ } catch {
473
+ $script:Pipe.Failed = $true
474
+ $script:Pipe.FailExit = -1
475
+ $script:Pipe.Output += ('[launch failed] ' + $_.Exception.Message + "`n")
476
+ Complete-Pipeline
477
+ return
478
+ }
479
+ $script:Pipe.Proc = $proc
480
+ if ($script:Timer -and -not $script:Headless) { $script:Timer.Start() }
481
+ }
482
+
483
+ function Set-LogBoxText {
484
+ param($Box, [string]$Text)
485
+ if (-not $Box) { return }
486
+ try {
487
+ $Box.Text = $Text
488
+ $Box.SelectionStart = $Box.TextLength
489
+ $Box.ScrollToCaret()
490
+ } catch { }
491
+ }
492
+
493
+ function Update-Pipeline {
494
+ if (-not $script:Pipe.Running) { return }
495
+
496
+ $out = Read-TextFileTolerant -Path $script:Pipe.OutFile
497
+ $err = Read-TextFileTolerant -Path $script:Pipe.ErrFile
498
+ $live = $out
499
+ if ($err) { $live = $out + $err }
500
+
501
+ if ($live -ne $script:Pipe.Output) {
502
+ $script:Pipe.Output = $live
503
+ Set-LogBoxText -Box $script:Pipe.LogBox -Text $live
504
+ }
505
+
506
+ $proc = $script:Pipe.Proc
507
+ if ($null -eq $proc) { return }
508
+ $exited = $false
509
+ try { $exited = $proc.HasExited } catch { $exited = $true }
510
+ if (-not $exited) { return }
511
+
512
+ # The process has exited but its handles may not have flushed yet: read once,
513
+ # give the files a moment, then keep whichever read is longer.
514
+ $first = Read-TextFileTolerant -Path $script:Pipe.OutFile
515
+ $firstErr = Read-TextFileTolerant -Path $script:Pipe.ErrFile
516
+ Start-Sleep -Milliseconds 120
517
+ $second = Read-TextFileTolerant -Path $script:Pipe.OutFile
518
+ $secondErr = Read-TextFileTolerant -Path $script:Pipe.ErrFile
519
+ if ($second.Length -lt $first.Length) { $second = $first }
520
+ if ($secondErr.Length -lt $firstErr.Length) { $secondErr = $firstErr }
521
+
522
+ $final = $second
523
+ if ($secondErr) { $final = $second + $secondErr }
524
+ $script:Pipe.Output = $final
525
+ Set-LogBoxText -Box $script:Pipe.LogBox -Text $final
526
+
527
+ $code = 0
528
+ try { $code = $proc.ExitCode } catch { $code = -1 }
529
+ try { $proc.Dispose() } catch { }
530
+ $script:Pipe.Proc = $null
531
+
532
+ $step = $script:Pipe.Steps[$script:Pipe.Index]
533
+ if ($code -ne 0 -and -not $step.IgnoreExit) {
534
+ $script:Pipe.Failed = $true
535
+ $script:Pipe.FailExit = $code
536
+ Complete-Pipeline
537
+ return
538
+ }
539
+ Start-NextPipelineStep
540
+ }
541
+
542
+ function Complete-Pipeline {
543
+ $script:Pipe.Running = $false
544
+ if ($script:Timer -and -not $script:Headless) { try { $script:Timer.Stop() } catch { } }
545
+ if ($script:Pipe.Proc) {
546
+ try { $script:Pipe.Proc.Dispose() } catch { }
547
+ $script:Pipe.Proc = $null
548
+ }
549
+ foreach ($path in @($script:Pipe.OutFile, $script:Pipe.ErrFile)) {
550
+ if ($path -and (Test-Path -LiteralPath $path)) {
551
+ try { Remove-Item -LiteralPath $path -Force -ErrorAction SilentlyContinue } catch { }
552
+ }
553
+ }
554
+ $onFinish = $script:Pipe.OnFinish
555
+ if ($onFinish) {
556
+ & $onFinish $script:Pipe.Failed $script:Pipe.FailExit $script:Pipe.Output
557
+ }
558
+ }
559
+
560
+ function Stop-Pipeline {
561
+ $proc = $script:Pipe.Proc
562
+ if ($proc) {
563
+ try {
564
+ if (-not $proc.HasExited) {
565
+ # /T matters: install.ps1 spawns python children, and killing only
566
+ # the shell would leave the agent behind.
567
+ & taskkill.exe /T /F /PID $proc.Id 2>$null | Out-Null
568
+ }
569
+ } catch { }
570
+ }
571
+ $script:Pipe.Running = $false
572
+ if ($script:Timer -and -not $script:Headless) { try { $script:Timer.Stop() } catch { } }
573
+ }
574
+
575
+ # ------------------------------------------------------------------------------
576
+ # Reading install.ps1's output back
577
+ # ------------------------------------------------------------------------------
578
+
579
+ # install.ps1 -DryRun prints six "[DRY-RUN] <label> : <value>" lines in a fixed
580
+ # order (Python, websockets, domain, config, autostart, node). The labels are
581
+ # localised, so this reads them positionally instead of matching on the label.
582
+ function Get-PreflightFacts {
583
+ param([string]$Text)
584
+ $facts = @{ Python = ''; Websockets = ''; Domain = ''; Config = ''; Autostart = ''; Node = '' }
585
+ $values = New-Object System.Collections.ArrayList
586
+ foreach ($line in ($Text -split "`n")) {
587
+ $trimmed = $line.Trim()
588
+ if (-not $trimmed.StartsWith('[DRY-RUN]')) { continue }
589
+ $match = [regex]::Match($trimmed, '^\[DRY-RUN\]\s+[^:]+:\s*(.*)$')
590
+ if ($match.Success) { [void]$values.Add($match.Groups[1].Value.Trim()) }
591
+ }
592
+ if ($values.Count -ge 1) { $facts.Python = $values[0] }
593
+ if ($values.Count -ge 2) { $facts.Websockets = $values[1] }
594
+ if ($values.Count -ge 3) { $facts.Domain = $values[2] }
595
+ if ($values.Count -ge 4) { $facts.Config = $values[3] }
596
+ if ($values.Count -ge 5) { $facts.Autostart = $values[4] }
597
+ # An older install.ps1 copy prints five lines: Node stays empty and the report
598
+ # simply omits that row.
599
+ if ($values.Count -ge 6) { $facts.Node = $values[5] }
600
+ return $facts
601
+ }
602
+
603
+ # The URL install.ps1 printed, or - when that line is unreadable - the canonical
604
+ # URL rebuilt from agent.env.
605
+ function Get-ResultUrl {
606
+ param(
607
+ [string]$Text,
608
+ [string]$Mode,
609
+ [string]$AgentEnvPath = ''
610
+ )
611
+ if (-not $AgentEnvPath) { $AgentEnvPath = $AgentEnvFile }
612
+
613
+ $found = [regex]::Matches($Text, $UrlPattern)
614
+ if ($found.Count -gt 0) {
615
+ return $found[$found.Count - 1].Value
616
+ }
617
+
618
+ if ($Mode -eq 'ssh') { return '' }
619
+
620
+ $values = @{}
621
+ $raw = Read-TextFileTolerant -Path $AgentEnvPath
622
+ foreach ($line in ($raw -split "`n")) {
623
+ $trimmed = $line.Trim()
624
+ if (-not $trimmed -or $trimmed.StartsWith('#') -or -not $trimmed.Contains('=')) { continue }
625
+ $index = $trimmed.IndexOf('=')
626
+ $values[$trimmed.Substring(0, $index).Trim()] = $trimmed.Substring($index + 1).Trim()
627
+ }
628
+ $gateway = "$($values['MESH_GATEWAY'])".Trim()
629
+ $user = "$($values['MESH_USER'])".Trim()
630
+ $token = "$($values['MESH_TOKEN'])".Trim()
631
+ if ($gateway -and $user -and $token) {
632
+ return ('https://' + $gateway + '/sse?user=' + $user + '&token=' + $token)
633
+ }
634
+ return ''
635
+ }
636
+
637
+ # ------------------------------------------------------------------------------
638
+ # Command construction
639
+ # ------------------------------------------------------------------------------
640
+
641
+ function Get-PowerShellExe {
642
+ $candidate = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe'
643
+ if (Test-Path -LiteralPath $candidate) { return $candidate }
644
+ return 'powershell.exe'
645
+ }
646
+
647
+ function New-ConsoleInstallerSteps {
648
+ param([string]$Mode, [string]$Node, [string]$Domain, [string]$Token)
649
+
650
+ $argList = @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', (Quote-Arg $ConsoleInstaller),
651
+ '-Lang', $script:UiLang)
652
+ if ($Mode -eq 'custom') {
653
+ $argList += @('-Mode', 'tunnel')
654
+ if ($Node) { $argList += @('-User', (Quote-Arg $Node)) }
655
+ if ($Domain) { $argList += @('-Gateway', (Quote-Arg $Domain)) }
656
+ if ($Token) { $argList += @('-Token', (Quote-Arg $Token)) }
657
+ }
658
+ return @(New-PipelineStep -File (Get-PowerShellExe) -Arguments ($argList -join ' ') `
659
+ -Label (Get-UiText -Key 'prog_running'))
660
+ }
661
+
662
+ function New-SshSteps {
663
+ param([string]$Target, [int]$Port, [string]$Domain)
664
+
665
+ $steps = New-Object System.Collections.ArrayList
666
+ $sshOptions = @('-o', 'StrictHostKeyChecking=no', '-o', 'BatchMode=yes')
667
+ $scpOptions = @('-o', 'StrictHostKeyChecking=no', '-o', 'BatchMode=yes')
668
+ $remoteRoot = '~/antigravity-mesh'
669
+
670
+ # 1. create the destination tree. ssh joins the trailing arguments with
671
+ # spaces and hands the result to the remote shell, so ~ expands remotely.
672
+ # Quoted for cmd.exe as well as for ssh: unquoted, the local command
673
+ # interpreter would treat the remote command's metacharacters as its own.
674
+ $mkdirCmd = 'mkdir -p ' + $remoteRoot + '/core ' + $remoteRoot + '/skills'
675
+ $mkdirArgs = $sshOptions + @('-p', "$Port", $Target, (Quote-Arg $mkdirCmd))
676
+ [void]$steps.Add((New-PipelineStep -File 'ssh.exe' -Arguments ($mkdirArgs -join ' ') `
677
+ -Label 'ssh: prepare remote directories'))
678
+
679
+ # 2. core/ - top-level .py files only. install.sh ships core/* verbatim, but a
680
+ # __pycache__ built for another interpreter is useless on the far side.
681
+ if (Test-Path -LiteralPath $CoreDir) {
682
+ $coreFiles = @(Get-ChildItem -LiteralPath $CoreDir -File -Filter '*.py' -ErrorAction SilentlyContinue |
683
+ ForEach-Object { $_.FullName })
684
+ if ($coreFiles.Count -gt 0) {
685
+ $scpCore = $scpOptions + @('-P', "$Port") +
686
+ @($coreFiles | ForEach-Object { Quote-Arg $_ }) +
687
+ @("${Target}:$remoteRoot/core/")
688
+ [void]$steps.Add((New-PipelineStep -File 'scp.exe' -Arguments ($scpCore -join ' ') -Label 'scp: core/*'))
689
+ }
690
+ }
691
+
692
+ # 3. install.sh - the remote side needs it to do the actual installation
693
+ $scpShell = $scpOptions + @('-P', "$Port", (Quote-Arg $ShellInstaller), "${Target}:$remoteRoot/")
694
+ [void]$steps.Add((New-PipelineStep -File 'scp.exe' -Arguments ($scpShell -join ' ') -Label 'scp: install.sh'))
695
+
696
+ # 4. skills/ - optional, exactly as install.sh treats it
697
+ if (Test-Path -LiteralPath $SkillsDir) {
698
+ $skillFiles = @(Get-ChildItem -LiteralPath $SkillsDir -File -ErrorAction SilentlyContinue |
699
+ ForEach-Object { $_.FullName })
700
+ if ($skillFiles.Count -gt 0) {
701
+ $scpSkills = $scpOptions + @('-P', "$Port") +
702
+ @($skillFiles | ForEach-Object { Quote-Arg $_ }) +
703
+ @("${Target}:$remoteRoot/skills/")
704
+ [void]$steps.Add((New-PipelineStep -File 'scp.exe' -Arguments ($scpSkills -join ' ') -Label 'scp: skills/*'))
705
+ }
706
+ }
707
+
708
+ # 5. run the installer on the remote host. install.sh resolves the shared
709
+ # domain itself (core/domain.py travels with it); --domain only overrides.
710
+ $remoteCmd = 'cd ' + $remoteRoot + ' && bash install.sh --quick --lang=' + $script:UiLang
711
+ if ($Domain) { $remoteCmd += ' --domain=' + $Domain }
712
+ $runArgs = $sshOptions + @('-p', "$Port", $Target, (Quote-Arg $remoteCmd))
713
+ [void]$steps.Add((New-PipelineStep -File 'ssh.exe' -Arguments ($runArgs -join ' ') -Label 'ssh: run install.sh'))
714
+
715
+ return $steps.ToArray()
716
+ }
717
+
718
+ function Get-CommandPreview {
719
+ param([string]$Mode, [string]$Node, [string]$Domain, [string]$Token, [string]$Target, [int]$Port)
720
+
721
+ if ($Mode -eq 'quick' -or $Mode -eq 'custom') {
722
+ # @() matters: PowerShell unrolls a function's return value, so a single
723
+ # step would arrive as a bare hashtable and $steps[0] would then be read
724
+ # as a key lookup that finds nothing.
725
+ $steps = @(New-ConsoleInstallerSteps -Mode $Mode -Node $Node -Domain $Domain -Token $Token)
726
+ return ($steps[0].File + ' ' + $steps[0].Arguments)
727
+ }
728
+ $lines = New-Object System.Collections.ArrayList
729
+ foreach ($step in (New-SshSteps -Target $Target -Port $Port -Domain $Domain)) {
730
+ [void]$lines.Add(($step.File + ' ' + $step.Arguments))
731
+ }
732
+ return ($lines -join "`r`n")
733
+ }
734
+
735
+ # ------------------------------------------------------------------------------
736
+ # UI state
737
+ # ------------------------------------------------------------------------------
738
+
739
+ $script:Form = $null
740
+ $script:Content = $null
741
+ $script:HeaderTitle = $null
742
+ $script:LangBox = $null
743
+ $script:StepLabel = $null
744
+ $script:BtnBack = $null
745
+ $script:BtnPrimary = $null
746
+ $script:BtnClose = $null
747
+ $script:Device = @{ Hostname = ''; Raw = ''; IsLaptop = $false; Os = '' }
748
+
749
+ $script:Page = 0
750
+ $script:Mode = 'quick'
751
+ $script:Node = ''
752
+ $script:Domain = ''
753
+ $script:Token = ''
754
+ $script:SshTarget = ''
755
+ $script:SshPort = 22
756
+
757
+ $script:Preflight = @{ Running = $false; Done = $false; Text = ''; Facts = $null; Problems = $false; Parsed = $false }
758
+ $script:Install = @{ Running = $false; Failed = $false; Exit = 0; Output = ''; Url = ''; Notice = ''; LinkNotice = '' }
759
+ $script:OptionError = ''
760
+
761
+ function New-Label {
762
+ param([string]$Text, [int]$X, [int]$Y, [int]$W, [int]$H, [switch]$Bold, [switch]$Dim)
763
+ $label = New-Object System.Windows.Forms.Label
764
+ $label.Text = $Text
765
+ $label.Location = New-Object System.Drawing.Point($X, $Y)
766
+ $label.Size = New-Object System.Drawing.Size($W, $H)
767
+ if ($Bold) { $label.Font = New-Object System.Drawing.Font('Segoe UI', 9, [System.Drawing.FontStyle]::Bold) }
768
+ if ($Dim) { $label.ForeColor = [System.Drawing.Color]::FromArgb(90, 90, 90) }
769
+ return $label
770
+ }
771
+
772
+ function New-GroupBox {
773
+ param([string]$Text, [int]$X, [int]$Y, [int]$W, [int]$H)
774
+ $group = New-Object System.Windows.Forms.GroupBox
775
+ $group.Text = $Text
776
+ $group.Location = New-Object System.Drawing.Point($X, $Y)
777
+ $group.Size = New-Object System.Drawing.Size($W, $H)
778
+ return $group
779
+ }
780
+
781
+ function New-ReadOnlyBox {
782
+ param([int]$X, [int]$Y, [int]$W, [int]$H, [switch]$MultiLine, [switch]$Monospace)
783
+ $box = New-Object System.Windows.Forms.TextBox
784
+ $box.Location = New-Object System.Drawing.Point($X, $Y)
785
+ $box.Size = New-Object System.Drawing.Size($W, $H)
786
+ $box.ReadOnly = $true
787
+ $box.BackColor = [System.Drawing.Color]::White
788
+ if ($MultiLine) {
789
+ $box.Multiline = $true
790
+ $box.ScrollBars = [System.Windows.Forms.ScrollBars]::Both
791
+ $box.WordWrap = $false
792
+ }
793
+ if ($Monospace) {
794
+ $box.Font = New-Object System.Drawing.Font('Consolas', 9)
795
+ }
796
+ return $box
797
+ }
798
+
799
+ function New-InputBox {
800
+ param([int]$X, [int]$Y, [int]$W, [string]$Text, [scriptblock]$OnChange)
801
+ $box = New-Object System.Windows.Forms.TextBox
802
+ $box.Location = New-Object System.Drawing.Point($X, $Y)
803
+ $box.Size = New-Object System.Drawing.Size($W, 23)
804
+ $box.Text = $Text
805
+ if ($OnChange) { $box.Add_TextChanged($OnChange) }
806
+ return $box
807
+ }
808
+
809
+ function Set-Language {
810
+ param([string]$Code)
811
+
812
+ if ($Code -eq $script:UiLang) { return }
813
+ $script:UiLang = $Code
814
+ if ($script:Form) { $script:Form.Text = Get-UiText -Key 'app_title' }
815
+ if ($script:HeaderTitle) { $script:HeaderTitle.Text = Get-UiText -Key 'app_title' }
816
+ Show-Page -Index $script:Page
817
+ }
818
+
819
+ # ---- page 0: welcome ---------------------------------------------------------
820
+
821
+ function Build-WelcomePage {
822
+ $script:Content.Controls.Add((New-Label -Text (Get-UiText -Key 'welcome_intro') -X 16 -Y 10 -W 760 -H 34))
823
+
824
+ $device = New-GroupBox -Text (Get-UiText -Key 'device_group') -X 16 -Y 50 -W 760 -H 100
825
+ $typeKey = 'device_desktop'
826
+ if ($script:Device.IsLaptop) { $typeKey = 'device_laptop' }
827
+ $rows = @(
828
+ @((Get-UiText -Key 'device_hostname'), $script:Device.Raw),
829
+ @((Get-UiText -Key 'device_type'), (Get-UiText -Key $typeKey)),
830
+ @((Get-UiText -Key 'device_os'), $script:Device.Os)
831
+ )
832
+ $y = 24
833
+ foreach ($row in $rows) {
834
+ $device.Controls.Add((New-Label -Text ($row[0] + ':') -X 14 -Y $y -W 120 -H 20))
835
+ $device.Controls.Add((New-Label -Text $row[1] -X 140 -Y $y -W 600 -H 20))
836
+ $y += 22
837
+ }
838
+ $script:Content.Controls.Add($device)
839
+
840
+ $preflight = New-GroupBox -Text (Get-UiText -Key 'preflight_group') -X 16 -Y 160 -W 760 -H 300
841
+ $box = New-ReadOnlyBox -X 14 -Y 22 -W 732 -H 266 -MultiLine -Monospace
842
+ if ($script:Preflight.Done) {
843
+ $box.Text = $script:Preflight.Text
844
+ } else {
845
+ $box.Text = Get-UiText -Key 'preflight_running'
846
+ }
847
+ $preflight.Controls.Add($box)
848
+ $script:Content.Controls.Add($preflight)
849
+
850
+ # Gated on Parsed: when the preflight could not run, the report above already
851
+ # says so, and an extra "no domain configured" would be a wrong diagnosis.
852
+ if ($script:Preflight.Done -and $script:Preflight.Parsed) {
853
+ if (-not (Test-DomainConfigured -Value $script:Preflight.Facts.Domain)) {
854
+ $hint = New-Label -Text (Get-UiText -Key 'preflight_no_domain') -X 20 -Y 466 -W 752 -H 34
855
+ $hint.ForeColor = [System.Drawing.Color]::FromArgb(176, 96, 0)
856
+ $script:Content.Controls.Add($hint)
857
+ }
858
+ }
859
+ }
860
+
861
+ # ---- page 1: mode ------------------------------------------------------------
862
+
863
+ function Build-ModePage {
864
+ $script:Content.Controls.Add((New-Label -Text (Get-UiText -Key 'mode_prompt') -X 16 -Y 12 -W 760 -H 24))
865
+
866
+ $group = New-GroupBox -Text (Get-UiText -Key 'page_mode_title') -X 16 -Y 44 -W 760 -H 220
867
+ $modes = @(
868
+ @{ Id = 'quick'; Title = 'mode_quick_title'; Desc = 'mode_quick_desc' },
869
+ @{ Id = 'custom'; Title = 'mode_custom_title'; Desc = 'mode_custom_desc' },
870
+ @{ Id = 'ssh'; Title = 'mode_ssh_title'; Desc = 'mode_ssh_desc' }
871
+ )
872
+ $y = 26
873
+ foreach ($mode in $modes) {
874
+ $radio = New-Object System.Windows.Forms.RadioButton
875
+ $radio.Text = Get-UiText -Key $mode.Title
876
+ $radio.Location = New-Object System.Drawing.Point(16, $y)
877
+ $radio.Size = New-Object System.Drawing.Size(720, 22)
878
+ $radio.Font = New-Object System.Drawing.Font('Segoe UI', 9, [System.Drawing.FontStyle]::Bold)
879
+ $radio.Tag = $mode.Id
880
+ if ($script:Mode -eq $mode.Id) { $radio.Checked = $true }
881
+ # Attached after Checked is set, so building the page cannot look like a
882
+ # user click and overwrite the selected mode.
883
+ $radio.Add_CheckedChanged({
884
+ if ($this.Checked) {
885
+ $script:Mode = [string]$this.Tag
886
+ $script:OptionError = ''
887
+ }
888
+ })
889
+ $group.Controls.Add($radio)
890
+ $group.Controls.Add((New-Label -Text (Get-UiText -Key $mode.Desc) -X 36 -Y ($y + 22) -W 700 -H 20 -Dim))
891
+ $y += 62
892
+ }
893
+ $script:Content.Controls.Add($group)
894
+ }
895
+
896
+ # ---- page 2: options ---------------------------------------------------------
897
+
898
+ function Build-OptionsPage {
899
+ if ($script:Mode -eq 'quick') {
900
+ $group = New-GroupBox -Text (Get-UiText -Key 'opt_quick_group') -X 16 -Y 12 -W 760 -H 120
901
+ $domain = "$($script:Domain)".Trim()
902
+ if (-not $domain) { $domain = '- (resolved by install.ps1)' }
903
+ $summary = New-ReadOnlyBox -X 14 -Y 24 -W 732 -H 84 -MultiLine -Monospace
904
+ $summary.Text = (Format-UiText -Key 'opt_quick_summary' -Values @($script:Node, $domain))
905
+ $group.Controls.Add($summary)
906
+ $script:Content.Controls.Add($group)
907
+ }
908
+
909
+ if ($script:Mode -eq 'custom') {
910
+ $group = New-GroupBox -Text (Get-UiText -Key 'page_options_title') -X 16 -Y 12 -W 760 -H 230
911
+
912
+ $group.Controls.Add((New-Label -Text ((Get-UiText -Key 'opt_node_label') + ':') -X 16 -Y 32 -W 200 -H 20))
913
+ $group.Controls.Add((New-InputBox -X 220 -Y 29 -W 520 -Text $script:Node -OnChange {
914
+ $script:Node = $this.Text; $script:OptionError = ''
915
+ }))
916
+ $group.Controls.Add((New-Label -Text (Get-UiText -Key 'opt_node_hint') -X 220 -Y 54 -W 520 -H 18 -Dim))
917
+
918
+ $group.Controls.Add((New-Label -Text ((Get-UiText -Key 'opt_domain_label') + ':') -X 16 -Y 90 -W 200 -H 20))
919
+ $group.Controls.Add((New-InputBox -X 220 -Y 87 -W 520 -Text $script:Domain -OnChange {
920
+ $script:Domain = $this.Text; $script:OptionError = ''
921
+ }))
922
+ $group.Controls.Add((New-Label -Text (Get-UiText -Key 'opt_domain_hint') -X 220 -Y 112 -W 520 -H 18 -Dim))
923
+
924
+ $group.Controls.Add((New-Label -Text ((Get-UiText -Key 'opt_token_label') + ':') -X 16 -Y 148 -W 200 -H 20))
925
+ $group.Controls.Add((New-InputBox -X 220 -Y 145 -W 520 -Text $script:Token -OnChange {
926
+ $script:Token = $this.Text; $script:OptionError = ''
927
+ }))
928
+ $group.Controls.Add((New-Label -Text (Get-UiText -Key 'opt_token_hint') -X 220 -Y 170 -W 520 -H 18 -Dim))
929
+
930
+ $script:Content.Controls.Add($group)
931
+ }
932
+
933
+ if ($script:Mode -eq 'ssh') {
934
+ $group = New-GroupBox -Text (Get-UiText -Key 'page_options_title') -X 16 -Y 12 -W 760 -H 210
935
+
936
+ $group.Controls.Add((New-Label -Text ((Get-UiText -Key 'opt_ssh_target_label') + ':') -X 16 -Y 32 -W 200 -H 20))
937
+ $group.Controls.Add((New-InputBox -X 220 -Y 29 -W 520 -Text $script:SshTarget -OnChange {
938
+ $script:SshTarget = $this.Text; $script:OptionError = ''
939
+ }))
940
+ $group.Controls.Add((New-Label -Text (Get-UiText -Key 'opt_ssh_target_hint') -X 220 -Y 54 -W 520 -H 18 -Dim))
941
+
942
+ $group.Controls.Add((New-Label -Text ((Get-UiText -Key 'opt_ssh_port_label') + ':') -X 16 -Y 90 -W 200 -H 20))
943
+ $group.Controls.Add((New-InputBox -X 220 -Y 87 -W 120 -Text "$($script:SshPort)" -OnChange {
944
+ $parsed = 0
945
+ if ([int]::TryParse($this.Text.Trim(), [ref]$parsed)) { $script:SshPort = $parsed }
946
+ $script:OptionError = ''
947
+ }))
948
+
949
+ $group.Controls.Add((New-Label -Text ((Get-UiText -Key 'opt_ssh_domain_label') + ':') -X 16 -Y 126 -W 200 -H 20))
950
+ $group.Controls.Add((New-InputBox -X 220 -Y 123 -W 520 -Text $script:Domain -OnChange {
951
+ $script:Domain = $this.Text; $script:OptionError = ''
952
+ }))
953
+
954
+ $group.Controls.Add((New-Label -Text (Format-UiText -Key 'opt_ssh_note' -Values @($script:UiLang)) `
955
+ -X 16 -Y 162 -W 730 -H 20 -Dim))
956
+ $script:Content.Controls.Add($group)
957
+ }
958
+
959
+ $preview = New-GroupBox -Text (Get-UiText -Key 'opt_summary_group') -X 16 -Y 252 -W 760 -H 210
960
+ $box = New-ReadOnlyBox -X 14 -Y 22 -W 732 -H 176 -MultiLine -Monospace
961
+ $box.Text = Get-CommandPreview -Mode $script:Mode -Node $script:Node -Domain $script:Domain `
962
+ -Token $script:Token -Target $script:SshTarget -Port $script:SshPort
963
+ $preview.Controls.Add($box)
964
+ $script:Content.Controls.Add($preview)
965
+
966
+ if ($script:OptionError) {
967
+ $errorLabel = New-Label -Text $script:OptionError -X 20 -Y 470 -W 750 -H 22
968
+ $errorLabel.ForeColor = [System.Drawing.Color]::FromArgb(176, 0, 0)
969
+ $script:Content.Controls.Add($errorLabel)
970
+ }
971
+ }
972
+
973
+ # ---- page 3: progress --------------------------------------------------------
974
+
975
+ function Build-ProgressPage {
976
+ $status = New-Label -Text (Get-UiText -Key 'prog_running') -X 16 -Y 12 -W 760 -H 22
977
+ $status.Font = New-Object System.Drawing.Font('Segoe UI', 9, [System.Drawing.FontStyle]::Bold)
978
+ $script:Content.Controls.Add($status)
979
+
980
+ $bar = New-Object System.Windows.Forms.ProgressBar
981
+ $bar.Location = New-Object System.Drawing.Point(16, 38)
982
+ $bar.Size = New-Object System.Drawing.Size(760, 16)
983
+ $bar.Style = [System.Windows.Forms.ProgressBarStyle]::Marquee
984
+ $bar.MarqueeAnimationSpeed = 30
985
+ $script:Content.Controls.Add($bar)
986
+
987
+ $script:Content.Controls.Add((New-Label -Text (Get-UiText -Key 'prog_log') -X 16 -Y 64 -W 760 -H 20))
988
+ $log = New-ReadOnlyBox -X 16 -Y 86 -W 760 -H 372 -MultiLine -Monospace
989
+ $log.Text = $script:Install.Output
990
+ $script:Content.Controls.Add($log)
991
+
992
+ $script:Pipe.LogBox = $log
993
+ $script:Pipe.StatusLabel = $status
994
+ }
995
+
996
+ # ---- page 4: result ----------------------------------------------------------
997
+
998
+ function Build-ResultPage {
999
+ $failed = $script:Install.Failed
1000
+ $titleKey = 'res_success_title'
1001
+ if ($failed) { $titleKey = 'res_fail_title' }
1002
+ $title = New-Label -Text (Get-UiText -Key $titleKey) -X 16 -Y 10 -W 760 -H 26 -Bold
1003
+ if ($failed) {
1004
+ $title.ForeColor = [System.Drawing.Color]::FromArgb(176, 0, 0)
1005
+ } else {
1006
+ $title.ForeColor = [System.Drawing.Color]::FromArgb(0, 120, 60)
1007
+ }
1008
+ $script:Content.Controls.Add($title)
1009
+
1010
+ $url = $script:Install.Url
1011
+
1012
+ $urlGroup = New-GroupBox -Text (Get-UiText -Key 'res_url_group') -X 16 -Y 42 -W 760 -H 96
1013
+ $urlBox = New-Object System.Windows.Forms.TextBox
1014
+ $urlBox.Location = New-Object System.Drawing.Point(14, 26)
1015
+ $urlBox.Size = New-Object System.Drawing.Size(600, 23)
1016
+ $urlBox.ReadOnly = $true
1017
+ $urlBox.BackColor = [System.Drawing.Color]::White
1018
+ $urlBox.Font = New-Object System.Drawing.Font('Consolas', 9)
1019
+ if ($url) { $urlBox.Text = $url } else { $urlBox.Text = Get-UiText -Key 'res_url_missing' }
1020
+ $urlGroup.Controls.Add($urlBox)
1021
+
1022
+ $copy = New-Object System.Windows.Forms.Button
1023
+ $copy.Text = Get-UiText -Key 'btn_copy'
1024
+ # Sizes itself: the Russian label is longer than the English one, and a fixed
1025
+ # width clipped it.
1026
+ $copy.AutoSize = $true
1027
+ $copy.AutoSizeMode = [System.Windows.Forms.AutoSizeMode]::GrowAndShrink
1028
+ $copy.MinimumSize = New-Object System.Drawing.Size(122, 26)
1029
+ $copy.Enabled = [bool]$url
1030
+ $copy.Add_Click({
1031
+ if (-not $script:Install.Url) { return }
1032
+ if (Set-UrlClipboard -Text $script:Install.Url) {
1033
+ $script:Install.Notice = Get-UiText -Key 'res_copied'
1034
+ } else {
1035
+ $script:Install.Notice = Get-UiText -Key 'res_copy_failed'
1036
+ }
1037
+ Show-Page -Index 4
1038
+ })
1039
+ $urlGroup.Controls.Add($copy)
1040
+ $copy.Location = New-Object System.Drawing.Point((746 - $copy.Width), 24)
1041
+
1042
+ $notice = "$($script:Install.Notice)"
1043
+ $noticeLabel = New-Label -Text $notice -X 16 -Y 56 -W 730 -H 20
1044
+ if ($notice) { $noticeLabel.ForeColor = [System.Drawing.Color]::FromArgb(0, 120, 60) }
1045
+ $urlGroup.Controls.Add($noticeLabel)
1046
+
1047
+ if ($script:Mode -eq 'ssh' -and -not $failed) {
1048
+ $urlGroup.Controls.Add((New-Label -Text (Get-UiText -Key 'res_remote_note') -X 16 -Y 74 -W 730 -H 18 -Dim))
1049
+ }
1050
+ $script:Content.Controls.Add($urlGroup)
1051
+
1052
+ $stepsGroup = New-GroupBox -Text (Get-UiText -Key 'res_steps_title') -X 16 -Y 146 -W 760 -H 126
1053
+ $stepsGroup.Controls.Add((New-Label -Text (Get-UiText -Key 'res_step1_prefix') -X 16 -Y 24 -W 250 -H 20))
1054
+
1055
+ # A real link, not a label: clicking it opens the page, and the button beside
1056
+ # it puts the address on the clipboard. A plain label could do neither.
1057
+ $link = New-Object System.Windows.Forms.LinkLabel
1058
+ $link.Text = $GeminiAppsUrl
1059
+ $link.Location = New-Object System.Drawing.Point(268, 24)
1060
+ $link.Size = New-Object System.Drawing.Size(310, 20)
1061
+ $link.Add_LinkClicked({ [void](Start-Url -Url $GeminiAppsUrl) })
1062
+ $stepsGroup.Controls.Add($link)
1063
+
1064
+ $copyLink = New-Object System.Windows.Forms.Button
1065
+ $copyLink.Text = Get-UiText -Key 'btn_copy_link'
1066
+ $copyLink.AutoSize = $true
1067
+ $copyLink.AutoSizeMode = [System.Windows.Forms.AutoSizeMode]::GrowAndShrink
1068
+ $copyLink.MinimumSize = New-Object System.Drawing.Size(112, 26)
1069
+ $copyLink.Add_Click({
1070
+ if (Set-UrlClipboard -Text $GeminiAppsUrl) {
1071
+ $script:Install.LinkNotice = Get-UiText -Key 'res_copied'
1072
+ } else {
1073
+ $script:Install.LinkNotice = Get-UiText -Key 'res_copy_failed'
1074
+ }
1075
+ Show-Page -Index 4
1076
+ })
1077
+ $stepsGroup.Controls.Add($copyLink)
1078
+ $copyLink.Location = New-Object System.Drawing.Point((746 - $copyLink.Width), 21)
1079
+
1080
+ $linkNotice = "$($script:Install.LinkNotice)"
1081
+ $linkNoticeLabel = New-Label -Text $linkNotice -X 16 -Y 94 -W 720 -H 20
1082
+ if ($linkNotice) { $linkNoticeLabel.ForeColor = [System.Drawing.Color]::FromArgb(0, 120, 60) }
1083
+ $stepsGroup.Controls.Add($linkNoticeLabel)
1084
+
1085
+ $stepsGroup.Controls.Add((New-Label -Text (Get-UiText -Key 'res_step2') -X 16 -Y 46 -W 730 -H 20))
1086
+ $stepsGroup.Controls.Add((New-Label -Text (Get-UiText -Key 'res_step3') -X 16 -Y 68 -W 730 -H 20))
1087
+ $script:Content.Controls.Add($stepsGroup)
1088
+
1089
+ if ($failed) {
1090
+ $hint = New-Label -Text (Get-UiText -Key 'res_fail_hint') -X 20 -Y 278 -W 752 -H 40
1091
+ $hint.ForeColor = [System.Drawing.Color]::FromArgb(176, 96, 0)
1092
+ $script:Content.Controls.Add($hint)
1093
+ } else {
1094
+ $configGroup = New-GroupBox -Text (Get-UiText -Key 'res_config_group') -X 16 -Y 278 -W 760 -H 110
1095
+ $rows = @()
1096
+ if ($script:Mode -eq 'ssh') {
1097
+ $rows = @(@((Get-UiText -Key 'res_config_file'), '~/antigravity-mesh (remote)'))
1098
+ } else {
1099
+ $rows = @(
1100
+ @((Get-UiText -Key 'res_config_file'), $AgentEnvFile),
1101
+ @((Get-UiText -Key 'res_autostart_file'), $AutostartFile),
1102
+ @((Get-UiText -Key 'res_agent_log'), $AgentLogFile)
1103
+ )
1104
+ }
1105
+ $y = 24
1106
+ foreach ($row in $rows) {
1107
+ $configGroup.Controls.Add((New-Label -Text ($row[0] + ':') -X 14 -Y $y -W 130 -H 20))
1108
+ $configGroup.Controls.Add((New-Label -Text $row[1] -X 150 -Y $y -W 460 -H 20 -Dim))
1109
+ $y += 24
1110
+ }
1111
+ $openConfig = New-Object System.Windows.Forms.Button
1112
+ $openConfig.Text = Get-UiText -Key 'btn_open_config'
1113
+ $openConfig.AutoSize = $true
1114
+ $openConfig.AutoSizeMode = [System.Windows.Forms.AutoSizeMode]::GrowAndShrink
1115
+ $openConfig.MinimumSize = New-Object System.Drawing.Size(128, 26)
1116
+ $openConfig.Add_Click({ [void](Open-Folder -Path $ConfigDir) })
1117
+ $configGroup.Controls.Add($openConfig)
1118
+ $openConfig.Location = New-Object System.Drawing.Point((746 - $openConfig.Width), 74)
1119
+ $script:Content.Controls.Add($configGroup)
1120
+ }
1121
+
1122
+ $logGroup = New-GroupBox -Text (Get-UiText -Key 'res_log_group') -X 16 -Y 396 -W 760 -H 84
1123
+ $log = New-ReadOnlyBox -X 14 -Y 20 -W 732 -H 56 -MultiLine -Monospace
1124
+ $log.Text = $script:Install.Output
1125
+ $logGroup.Controls.Add($log)
1126
+ $script:Content.Controls.Add($logGroup)
1127
+ }
1128
+
1129
+ # ---- navigation --------------------------------------------------------------
1130
+
1131
+ # Buttons grow with their label, so the footer is positioned from the right edge
1132
+ # instead of at fixed coordinates: "Open Gemini Apps" is far wider than "Next >",
1133
+ # and the Russian labels are wider still.
1134
+ function Format-FooterButtons {
1135
+ $right = $script:Form.ClientSize.Width - 16
1136
+ foreach ($button in @($script:BtnClose, $script:BtnPrimary, $script:BtnBack)) {
1137
+ $right = $right - $button.Width
1138
+ $button.Location = New-Object System.Drawing.Point($right, 14)
1139
+ $right = $right - 8
1140
+ }
1141
+ }
1142
+
1143
+ function Show-Page {
1144
+ param([int]$Index)
1145
+
1146
+ $script:Page = $Index
1147
+ $script:Content.Controls.Clear()
1148
+ $script:Pipe.LogBox = $null
1149
+ $script:Pipe.StatusLabel = $null
1150
+
1151
+ $script:BtnBack.Visible = $false
1152
+ # Re-applied on every page: the label was set once in Initialize-Form, so a
1153
+ # runtime language switch left this button in the previous language.
1154
+ $script:BtnBack.Text = Get-UiText -Key 'btn_back'
1155
+ $script:BtnPrimary.Visible = $true
1156
+ $script:BtnPrimary.Enabled = $true
1157
+ $script:BtnClose.Text = Get-UiText -Key 'btn_close'
1158
+
1159
+ switch ($Index) {
1160
+ 0 {
1161
+ $script:StepLabel.Text = Format-UiText -Key 'step_indicator' -Values @(1, 3)
1162
+ Build-WelcomePage
1163
+ $script:BtnPrimary.Text = Get-UiText -Key 'btn_next'
1164
+ }
1165
+ 1 {
1166
+ $script:StepLabel.Text = Format-UiText -Key 'step_indicator' -Values @(2, 3)
1167
+ $script:BtnBack.Visible = $true
1168
+ Build-ModePage
1169
+ $script:BtnPrimary.Text = Get-UiText -Key 'btn_next'
1170
+ }
1171
+ 2 {
1172
+ $script:StepLabel.Text = Format-UiText -Key 'step_indicator' -Values @(3, 3)
1173
+ $script:BtnBack.Visible = $true
1174
+ Build-OptionsPage
1175
+ $script:BtnPrimary.Text = Get-UiText -Key 'btn_install'
1176
+ }
1177
+ 3 {
1178
+ $script:StepLabel.Text = Get-UiText -Key 'page_progress_title'
1179
+ Build-ProgressPage
1180
+ $script:BtnPrimary.Visible = $false
1181
+ }
1182
+ 4 {
1183
+ $script:StepLabel.Text = Get-UiText -Key 'page_result_title'
1184
+ Build-ResultPage
1185
+ $script:BtnPrimary.Text = Get-UiText -Key 'btn_open_gemini'
1186
+ $script:BtnClose.Text = Get-UiText -Key 'btn_finish'
1187
+ }
1188
+ }
1189
+
1190
+ Format-FooterButtons
1191
+ }
1192
+
1193
+ # ---- install -----------------------------------------------------------------
1194
+
1195
+ function Test-InstallOptions {
1196
+ if ($script:Mode -eq 'quick' -or $script:Mode -eq 'custom') {
1197
+ if (-not (Test-Path -LiteralPath $ConsoleInstaller)) {
1198
+ $script:OptionError = Format-UiText -Key 'opt_err_installer_missing' -Values @($ConsoleInstaller)
1199
+ return $false
1200
+ }
1201
+ }
1202
+ if ($script:Mode -eq 'custom') {
1203
+ $node = "$($script:Node)".Trim()
1204
+ if (-not $node) {
1205
+ $script:OptionError = Get-UiText -Key 'opt_err_node_required'
1206
+ return $false
1207
+ }
1208
+ if ($node -notmatch '^[a-z0-9_-]+$') {
1209
+ $script:OptionError = Get-UiText -Key 'opt_err_node_chars'
1210
+ return $false
1211
+ }
1212
+ if (-not "$($script:Domain)".Trim()) {
1213
+ $script:OptionError = Get-UiText -Key 'opt_err_domain_required'
1214
+ return $false
1215
+ }
1216
+ }
1217
+ if ($script:Mode -eq 'ssh') {
1218
+ $target = "$($script:SshTarget)".Trim()
1219
+ if (-not $target -or $target -notmatch '^[^@\s]+@[^@\s]+$') {
1220
+ $script:OptionError = Get-UiText -Key 'opt_err_ssh_required'
1221
+ return $false
1222
+ }
1223
+ if ($script:SshPort -lt 1 -or $script:SshPort -gt 65535) {
1224
+ $script:OptionError = Get-UiText -Key 'opt_err_ssh_port'
1225
+ return $false
1226
+ }
1227
+ if (-not (Test-Path -LiteralPath $ShellInstaller)) {
1228
+ $script:OptionError = Format-UiText -Key 'opt_err_install_sh_missing' -Values @($ShellInstaller)
1229
+ return $false
1230
+ }
1231
+ if (-not (Test-Path -LiteralPath $CoreDir)) {
1232
+ $script:OptionError = Format-UiText -Key 'opt_err_core_missing' -Values @($CoreDir)
1233
+ return $false
1234
+ }
1235
+ if (-not (Get-Command 'ssh.exe' -ErrorAction SilentlyContinue) -or
1236
+ -not (Get-Command 'scp.exe' -ErrorAction SilentlyContinue)) {
1237
+ $script:OptionError = Get-UiText -Key 'opt_err_ssh_missing'
1238
+ return $false
1239
+ }
1240
+ }
1241
+ $script:OptionError = ''
1242
+ return $true
1243
+ }
1244
+
1245
+ function Start-Installation {
1246
+ if (-not (Test-InstallOptions)) {
1247
+ Show-Page -Index 2
1248
+ return
1249
+ }
1250
+
1251
+ $script:Install = @{ Running = $true; Failed = $false; Exit = 0; Output = ''; Url = ''; Notice = ''; LinkNotice = '' }
1252
+ Show-Page -Index 3
1253
+
1254
+ if ($script:Mode -eq 'ssh') {
1255
+ $steps = New-SshSteps -Target "$($script:SshTarget)".Trim() -Port $script:SshPort `
1256
+ -Domain "$($script:Domain)".Trim()
1257
+ } else {
1258
+ $steps = New-ConsoleInstallerSteps -Mode $script:Mode -Node "$($script:Node)".Trim() `
1259
+ -Domain "$($script:Domain)".Trim() -Token "$($script:Token)".Trim()
1260
+ }
1261
+
1262
+ Reset-Pipeline -Steps $steps -OnFinish {
1263
+ param($failed, $exitCode, $output)
1264
+ $script:Install.Running = $false
1265
+ $script:Install.Failed = [bool]$failed
1266
+ $script:Install.Exit = $exitCode
1267
+ $script:Install.Output = $output
1268
+ if (-not $failed) {
1269
+ $script:Install.Url = Get-ResultUrl -Text $output -Mode $script:Mode
1270
+ }
1271
+ Show-Page -Index 4
1272
+ } -LogBox $script:Pipe.LogBox -StatusLabel $script:Pipe.StatusLabel
1273
+ }
1274
+
1275
+ function Start-Preflight {
1276
+ if ($script:Preflight.Running -or $script:Preflight.Done) { return }
1277
+ if (-not (Test-Path -LiteralPath $ConsoleInstaller)) {
1278
+ $script:Preflight.Done = $true
1279
+ $script:Preflight.Text = Format-UiText -Key 'opt_err_installer_missing' -Values @($ConsoleInstaller)
1280
+ return
1281
+ }
1282
+ $script:Preflight.Running = $true
1283
+ $argList = @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', (Quote-Arg $ConsoleInstaller),
1284
+ '-DryRun', '-Lang', $script:UiLang) -join ' '
1285
+ $steps = @(New-PipelineStep -File (Get-PowerShellExe) -Arguments $argList `
1286
+ -Label (Get-UiText -Key 'preflight_group'))
1287
+ Reset-Pipeline -Steps $steps -OnFinish {
1288
+ param($failed, $exitCode, $output)
1289
+ $script:Preflight.Running = $false
1290
+ $script:Preflight.Done = $true
1291
+ $facts = Get-PreflightFacts -Text $output
1292
+ $script:Preflight.Facts = $facts
1293
+ # Whether the check actually produced anything. A run that could not even
1294
+ # write its output files parses to nothing, and that is not the same thing
1295
+ # as "no domain configured".
1296
+ $script:Preflight.Parsed = [bool]($facts.Python -or $facts.Domain)
1297
+ # exit 2 is install.ps1's "preflight found problems" code, not a failure
1298
+ # of the check itself, so the report is still worth showing.
1299
+ $script:Preflight.Problems = ($exitCode -eq 2)
1300
+ if (-not $facts.Python -and -not $facts.Domain) {
1301
+ $script:Preflight.Text = (Format-UiText -Key 'preflight_failed' -Values @("exit $exitCode")) +
1302
+ "`r`n`r`n" + $output
1303
+ } else {
1304
+ $lines = New-Object System.Collections.ArrayList
1305
+ [void]$lines.Add(((Get-UiText -Key 'preflight_python') + ' : ' + $facts.Python))
1306
+ [void]$lines.Add(((Get-UiText -Key 'preflight_websockets') + ' : ' + $facts.Websockets))
1307
+ [void]$lines.Add(((Get-UiText -Key 'preflight_domain') + ' : ' + $facts.Domain))
1308
+ [void]$lines.Add(((Get-UiText -Key 'preflight_config') + ' : ' + $facts.Config))
1309
+ [void]$lines.Add(((Get-UiText -Key 'preflight_autostart') + ' : ' + $facts.Autostart))
1310
+ if ($facts.Node) {
1311
+ [void]$lines.Add(((Get-UiText -Key 'preflight_node') + ' : ' + $facts.Node))
1312
+ }
1313
+ if ($script:Preflight.Problems) {
1314
+ [void]$lines.Add('')
1315
+ [void]$lines.Add((Get-UiText -Key 'preflight_problems'))
1316
+ }
1317
+ $script:Preflight.Text = ($lines -join "`r`n")
1318
+ }
1319
+ if ($script:Page -eq 0) { Show-Page -Index 0 }
1320
+ }
1321
+ if ($script:Page -eq 0) { Show-Page -Index 0 }
1322
+ }
1323
+
1324
+ # ------------------------------------------------------------------------------
1325
+ # Form construction
1326
+ # ------------------------------------------------------------------------------
1327
+
1328
+ function Initialize-Form {
1329
+ $script:Form = New-Object System.Windows.Forms.Form
1330
+ # The layout below is absolute pixels. With the default AutoScaleMode of Font,
1331
+ # WinForms resized every control from the form's default font to Segoe UI 9,
1332
+ # which pushed the right-hand column and the language box past the window
1333
+ # edge. Measured on Windows 11, PowerShell 5.1.
1334
+ $script:Form.AutoScaleMode = [System.Windows.Forms.AutoScaleMode]::None
1335
+ $script:Form.Text = Get-UiText -Key 'app_title'
1336
+
1337
+ $script:Form.ClientSize = New-Object System.Drawing.Size(804, 620)
1338
+ $script:Form.StartPosition = [System.Windows.Forms.FormStartPosition]::CenterScreen
1339
+ $script:Form.FormBorderStyle = [System.Windows.Forms.FormBorderStyle]::Sizable
1340
+ $script:Form.MaximizeBox = $true
1341
+ $script:Form.MinimumSize = New-Object System.Drawing.Size(720, 560)
1342
+ $script:Form.Font = New-Object System.Drawing.Font('Segoe UI', 9)
1343
+
1344
+ $header = New-Object System.Windows.Forms.Panel
1345
+ $header.Dock = [System.Windows.Forms.DockStyle]::Top
1346
+ $header.Height = 56
1347
+ $header.BackColor = [System.Drawing.Color]::FromArgb(245, 247, 250)
1348
+ $script:Form.Controls.Add($header)
1349
+
1350
+ $script:HeaderTitle = New-Label -Text (Get-UiText -Key 'app_title') -X 16 -Y 14 -W 520 -H 28 -Bold
1351
+ $script:HeaderTitle.Font = New-Object System.Drawing.Font('Segoe UI', 11, [System.Drawing.FontStyle]::Bold)
1352
+ $header.Controls.Add($script:HeaderTitle)
1353
+
1354
+ $languageLabel = New-Label -Text ((Get-UiText -Key 'lang_label') + ':') -X 596 -Y 20 -W 74 -H 20
1355
+ $languageLabel.Anchor = [System.Windows.Forms.AnchorStyles]::Top -bor [System.Windows.Forms.AnchorStyles]::Right
1356
+ $header.Controls.Add($languageLabel)
1357
+
1358
+ $script:LangBox = New-Object System.Windows.Forms.ComboBox
1359
+ $script:LangBox.DropDownStyle = [System.Windows.Forms.ComboBoxStyle]::DropDownList
1360
+ $script:LangBox.Location = New-Object System.Drawing.Point(672, 17)
1361
+ $script:LangBox.Size = New-Object System.Drawing.Size(118, 23)
1362
+ $script:LangBox.Anchor = [System.Windows.Forms.AnchorStyles]::Top -bor [System.Windows.Forms.AnchorStyles]::Right
1363
+ [void]$script:LangBox.Items.Add('English')
1364
+ [void]$script:LangBox.Items.Add('Russian')
1365
+ # SelectedIndex is set before the handler is attached, so restoring the
1366
+ # startup language does not fire a rebuild.
1367
+ if ($script:UiLang -eq 'ru') { $script:LangBox.SelectedIndex = 1 } else { $script:LangBox.SelectedIndex = 0 }
1368
+
1369
+ $script:LangBox.Add_SelectedIndexChanged({
1370
+ $code = 'en'
1371
+ if ($this.SelectedIndex -eq 1) { $code = 'ru' }
1372
+ Set-Language -Code $code
1373
+ })
1374
+ $header.Controls.Add($script:LangBox)
1375
+
1376
+ $footer = New-Object System.Windows.Forms.Panel
1377
+ $footer.Dock = [System.Windows.Forms.DockStyle]::Bottom
1378
+ $footer.Height = 56
1379
+ $footer.BackColor = [System.Drawing.Color]::FromArgb(245, 247, 250)
1380
+ $script:Form.Controls.Add($footer)
1381
+
1382
+ $script:StepLabel = New-Label -Text '' -X 16 -Y 20 -W 360 -H 20 -Dim
1383
+ $footer.Controls.Add($script:StepLabel)
1384
+
1385
+ $script:BtnClose = New-Object System.Windows.Forms.Button
1386
+ $script:BtnClose.Text = Get-UiText -Key 'btn_close'
1387
+ $script:BtnClose.Location = New-Object System.Drawing.Point(672, 14)
1388
+ $script:BtnClose.AutoSize = $true
1389
+ $script:BtnClose.AutoSizeMode = [System.Windows.Forms.AutoSizeMode]::GrowAndShrink
1390
+ $script:BtnClose.MinimumSize = New-Object System.Drawing.Size(118, 28)
1391
+ $script:BtnClose.Anchor = [System.Windows.Forms.AnchorStyles]::Top -bor [System.Windows.Forms.AnchorStyles]::Right
1392
+ $script:BtnClose.Add_Click({ $script:Form.Close() })
1393
+ $footer.Controls.Add($script:BtnClose)
1394
+
1395
+ $script:BtnPrimary = New-Object System.Windows.Forms.Button
1396
+ $script:BtnPrimary.Location = New-Object System.Drawing.Point(548, 14)
1397
+ $script:BtnPrimary.AutoSize = $true
1398
+ $script:BtnPrimary.AutoSizeMode = [System.Windows.Forms.AutoSizeMode]::GrowAndShrink
1399
+ $script:BtnPrimary.MinimumSize = New-Object System.Drawing.Size(118, 28)
1400
+ $script:BtnPrimary.Anchor = [System.Windows.Forms.AnchorStyles]::Top -bor [System.Windows.Forms.AnchorStyles]::Right
1401
+ # One dispatcher for every page: attaching a handler per page would stack
1402
+ # them, and the second click would run the previous page's action as well.
1403
+ $script:BtnPrimary.Add_Click({
1404
+ switch ($script:Page) {
1405
+ 0 { Show-Page -Index 1 }
1406
+ 1 { $script:OptionError = ''; Show-Page -Index 2 }
1407
+ 2 { Start-Installation }
1408
+ 4 { [void](Start-Url -Url $GeminiAppsUrl) }
1409
+ }
1410
+ })
1411
+ $footer.Controls.Add($script:BtnPrimary)
1412
+
1413
+ # Enter activates the primary button, so the wizard can be driven from the
1414
+ # keyboard alone. On the progress page the button is hidden and the
1415
+ # dispatcher has no action for that page, so Enter is harmless there.
1416
+ $script:Form.AcceptButton = $script:BtnPrimary
1417
+
1418
+ $script:BtnBack = New-Object System.Windows.Forms.Button
1419
+ $script:BtnBack.Text = Get-UiText -Key 'btn_back'
1420
+ $script:BtnBack.Location = New-Object System.Drawing.Point(424, 14)
1421
+ $script:BtnBack.AutoSize = $true
1422
+ $script:BtnBack.AutoSizeMode = [System.Windows.Forms.AutoSizeMode]::GrowAndShrink
1423
+ $script:BtnBack.MinimumSize = New-Object System.Drawing.Size(118, 28)
1424
+ $script:BtnBack.Anchor = [System.Windows.Forms.AnchorStyles]::Top -bor [System.Windows.Forms.AnchorStyles]::Right
1425
+ $script:BtnBack.Add_Click({
1426
+ if ($script:Page -gt 0 -and $script:Page -lt 3) {
1427
+ $script:OptionError = ''
1428
+ Show-Page -Index ($script:Page - 1)
1429
+ }
1430
+ })
1431
+ $footer.Controls.Add($script:BtnBack)
1432
+
1433
+ # Positioned explicitly rather than docked Fill: docking order depends on
1434
+ # z-order, and this form is fixed-size anyway.
1435
+ $script:Content = New-Object System.Windows.Forms.Panel
1436
+ $script:Content.Location = New-Object System.Drawing.Point(0, 56)
1437
+ $script:Content.Size = New-Object System.Drawing.Size(804, 508)
1438
+ $script:Content.Anchor = [System.Windows.Forms.AnchorStyles]::Top -bor `
1439
+ [System.Windows.Forms.AnchorStyles]::Bottom -bor `
1440
+ [System.Windows.Forms.AnchorStyles]::Left -bor `
1441
+ [System.Windows.Forms.AnchorStyles]::Right
1442
+ $script:Content.AutoScroll = $true
1443
+ $script:Content.BackColor = [System.Drawing.Color]::White
1444
+ $script:Form.Controls.Add($script:Content)
1445
+
1446
+ $script:Timer = New-Object System.Windows.Forms.Timer
1447
+ $script:Timer.Interval = 250
1448
+ $script:Timer.Add_Tick({ Update-Pipeline })
1449
+
1450
+ $script:Form.Add_FormClosing({
1451
+ param($sender, $eventArgs)
1452
+ if ($script:Pipe.Running) {
1453
+ $answer = [System.Windows.Forms.MessageBox]::Show(
1454
+ (Get-UiText -Key 'msg_cancel_confirm'),
1455
+ (Get-UiText -Key 'msg_cancel_title'),
1456
+ [System.Windows.Forms.MessageBoxButtons]::YesNo,
1457
+ [System.Windows.Forms.MessageBoxIcon]::Question)
1458
+ if ($answer -ne [System.Windows.Forms.DialogResult]::Yes) {
1459
+ $eventArgs.Cancel = $true
1460
+ return
1461
+ }
1462
+ Stop-Pipeline
1463
+ }
1464
+ })
1465
+ }
1466
+
1467
+ # ------------------------------------------------------------------------------
1468
+ # Self-test
1469
+ # ------------------------------------------------------------------------------
1470
+
1471
+ $script:SelfTestResults = New-Object System.Collections.ArrayList
1472
+
1473
+ function Add-SelfTestResult {
1474
+ param([string]$Name, [bool]$Pass, [string]$Detail = '')
1475
+ [void]$script:SelfTestResults.Add([pscustomobject]@{ name = $Name; pass = $Pass; detail = $Detail })
1476
+ }
1477
+
1478
+ function Invoke-SelfTest {
1479
+ $script:Headless = $true
1480
+ $script:SelfTestResults = New-Object System.Collections.ArrayList
1481
+ # The language the entry point actually settled on, before this self-test
1482
+ # starts switching languages for its own checks.
1483
+ $entryLang = $script:UiLang
1484
+
1485
+ # 1. the source must stay pure ASCII, or PowerShell 5.1 will not parse it
1486
+ # when the file is read with the ANSI code page
1487
+ $nonAscii = -1
1488
+ try {
1489
+ $bytes = [System.IO.File]::ReadAllBytes($ScriptPath)
1490
+ $nonAscii = 0
1491
+ foreach ($b in $bytes) { if ($b -gt 127) { $nonAscii++ } }
1492
+ } catch { $nonAscii = -1 }
1493
+ Add-SelfTestResult 'source_is_ascii' ($nonAscii -eq 0) "non_ascii_bytes=$nonAscii"
1494
+
1495
+ # 2. both languages carry exactly the same keys
1496
+ $enKeys = @($script:Strings.en.PSObject.Properties.Name | Sort-Object)
1497
+ $ruKeys = @($script:Strings.ru.PSObject.Properties.Name | Sort-Object)
1498
+ $missing = @($enKeys | Where-Object { $ruKeys -notcontains $_ })
1499
+ $extra = @($ruKeys | Where-Object { $enKeys -notcontains $_ })
1500
+ Add-SelfTestResult 'language_keys_match' (($missing.Count -eq 0) -and ($extra.Count -eq 0)) `
1501
+ ("missing_in_ru=[" + ($missing -join ',') + "] extra_in_ru=[" + ($extra -join ',') + "]")
1502
+
1503
+ # 3. no key resolves to itself, which is what Get-UiText returns on a typo
1504
+ $unresolved = @()
1505
+ foreach ($key in $enKeys) {
1506
+ if ((Get-UiText -Key $key) -eq $key) { $unresolved += $key }
1507
+ }
1508
+ Add-SelfTestResult 'keys_resolve' ($unresolved.Count -eq 0) ("unresolved=[" + ($unresolved -join ',') + "]")
1509
+
1510
+ # 4. the tolerant reader decodes UTF-8 Russian rather than mangling it
1511
+ $probe = Join-Path (Get-ScratchDirectory) ('mesh-gui-selftest-' + [Guid]::NewGuid().ToString('N') + '.txt')
1512
+ $cyrillic = [string][char]0x041F + [char]0x0440 + [char]0x0438 + [char]0x0432 + [char]0x0435 + [char]0x0442
1513
+ try {
1514
+ [System.IO.File]::WriteAllText($probe, $cyrillic, (New-Object System.Text.UTF8Encoding($false)))
1515
+ $readBack = Read-TextFileTolerant -Path $probe
1516
+ Add-SelfTestResult 'tolerant_reader_utf8' ($readBack -eq $cyrillic) ("read='" + $readBack + "'")
1517
+ } catch {
1518
+ Add-SelfTestResult 'tolerant_reader_utf8' $false $_.Exception.Message
1519
+ } finally {
1520
+ if (Test-Path -LiteralPath $probe) { Remove-Item -LiteralPath $probe -Force -ErrorAction SilentlyContinue }
1521
+ }
1522
+
1523
+ # 5. the URL parser finds the URL in output shaped like install.ps1's box
1524
+ $boxOutput = ' +----------------------------------------------------------+' + "`n" +
1525
+ ' | MCP SERVER URL (COPY THIS): |' + "`n" +
1526
+ ' | |' + "`n" +
1527
+ ' | https://mesh.example.test/sse?user=node-one&token=abc123 |' + "`n" +
1528
+ ' | |' + "`n" +
1529
+ ' | [OK] URL COPIED TO CLIPBOARD! |' + "`n" +
1530
+ ' +----------------------------------------------------------+' + "`n"
1531
+ $parsed = Get-ResultUrl -Text $boxOutput -Mode 'custom'
1532
+ Add-SelfTestResult 'url_parser_box' `
1533
+ ($parsed -eq 'https://mesh.example.test/sse?user=node-one&token=abc123') ("parsed='" + $parsed + "'")
1534
+
1535
+ $parsedStandalone = Get-ResultUrl -Text 'http://localhost:8096/sse' -Mode 'custom'
1536
+ Add-SelfTestResult 'url_parser_standalone' ($parsedStandalone -eq 'http://localhost:8096/sse') `
1537
+ ("parsed='" + $parsedStandalone + "'")
1538
+
1539
+ # 6. the agent.env fallback rebuilds the canonical URL
1540
+ $envProbe = Join-Path (Get-ScratchDirectory) ('mesh-gui-selftest-env-' + [Guid]::NewGuid().ToString('N') + '.env')
1541
+ try {
1542
+ $envText = "MESH_GATEWAY=mesh.example.test`r`nMESH_USER=node-two`r`nMESH_TOKEN=tok456`r`n"
1543
+ [System.IO.File]::WriteAllText($envProbe, $envText, (New-Object System.Text.UTF8Encoding($false)))
1544
+ $fromEnv = Get-ResultUrl -Text 'no url in this text' -Mode 'custom' -AgentEnvPath $envProbe
1545
+ Add-SelfTestResult 'url_from_agent_env' `
1546
+ ($fromEnv -eq 'https://mesh.example.test/sse?user=node-two&token=tok456') ("parsed='" + $fromEnv + "'")
1547
+ } catch {
1548
+ Add-SelfTestResult 'url_from_agent_env' $false $_.Exception.Message
1549
+ } finally {
1550
+ if (Test-Path -LiteralPath $envProbe) { Remove-Item -LiteralPath $envProbe -Force -ErrorAction SilentlyContinue }
1551
+ }
1552
+
1553
+ # 7. command previews build for every mode (this also exercises New-SshSteps
1554
+ # without opening a single connection)
1555
+ $previewErrors = @()
1556
+ $savedMode = $script:Mode
1557
+ foreach ($mode in @('quick', 'custom', 'ssh')) {
1558
+ $script:Mode = $mode
1559
+ try {
1560
+ $preview = Get-CommandPreview -Mode $mode -Node 'node-x' -Domain 'mesh.example.test' `
1561
+ -Token 'tok' -Target 'user@host' -Port 22
1562
+ # -notmatch on an array filters rather than compares, and the filtered
1563
+ # result is truthy; collapse to one string first.
1564
+ $previewText = ($preview | Out-String)
1565
+ if (-not $previewText.Trim()) { $previewErrors += "${mode}: empty preview" }
1566
+ if ($mode -eq 'ssh' -and $previewText -notmatch 'install\.sh --quick --lang=') {
1567
+ $previewErrors += "${mode}: remote install command missing"
1568
+ }
1569
+ } catch {
1570
+ $previewErrors += "${mode}: $($_.Exception.Message)"
1571
+ }
1572
+ }
1573
+ $script:Mode = $savedMode
1574
+ Add-SelfTestResult 'command_previews' ($previewErrors.Count -eq 0) ($previewErrors -join ' | ')
1575
+
1576
+ # 8. every page builds in every language and in every mode
1577
+ $script:Device = Get-DeviceInfo
1578
+ Initialize-Form
1579
+ $buildErrors = @()
1580
+ $savedLang = $script:UiLang
1581
+ foreach ($code in @('en', 'ru')) {
1582
+ $script:UiLang = $code
1583
+ foreach ($mode in @('quick', 'custom', 'ssh')) {
1584
+ $script:Mode = $mode
1585
+ foreach ($index in @(0, 1, 2, 3, 4)) {
1586
+ try {
1587
+ Show-Page -Index $index
1588
+ } catch {
1589
+ $buildErrors += "$code/${mode}/page${index}: $($_.Exception.Message)"
1590
+ }
1591
+ }
1592
+ }
1593
+ }
1594
+ $script:UiLang = $savedLang
1595
+ $script:Mode = $savedMode
1596
+ Add-SelfTestResult 'pages_build' ($buildErrors.Count -eq 0) ($buildErrors -join ' | ')
1597
+
1598
+ # 9. the process pipeline really captures output and reports the exit code
1599
+ $probeStep = New-PipelineStep -File (Get-PowerShellExe) `
1600
+ -Arguments '-NoProfile -Command "Write-Host ''https://pipe.example.test/sse?user=probe&token=tk''"' `
1601
+ -Label 'selftest-success'
1602
+ $script:PipeResult = $null
1603
+ Reset-Pipeline -Steps @($probeStep) -OnFinish {
1604
+ param($failed, $exitCode, $output)
1605
+ $script:PipeResult = @{ Failed = $failed; Exit = $exitCode; Output = $output }
1606
+ }
1607
+ $deadline = (Get-Date).AddSeconds(60)
1608
+ while ($script:Pipe.Running -and (Get-Date) -lt $deadline) {
1609
+ Update-Pipeline
1610
+ Start-Sleep -Milliseconds 120
1611
+ }
1612
+ $pipeOk = $false
1613
+ $pipeDetail = 'pipeline did not finish'
1614
+ if ($script:PipeResult) {
1615
+ $captured = Get-ResultUrl -Text $script:PipeResult.Output -Mode 'custom'
1616
+ $pipeOk = (-not $script:PipeResult.Failed) -and ($captured -eq 'https://pipe.example.test/sse?user=probe&token=tk')
1617
+ $pipeDetail = "failed=$($script:PipeResult.Failed) exit=$($script:PipeResult.Exit) url='$captured'"
1618
+ }
1619
+ Add-SelfTestResult 'pipeline_captures_output' $pipeOk $pipeDetail
1620
+
1621
+ # 10. a non-zero exit is reported as a failure
1622
+ $failStep = New-PipelineStep -File (Get-PowerShellExe) -Arguments '-NoProfile -Command "exit 7"' -Label 'selftest-failure'
1623
+ $script:PipeResult = $null
1624
+ Reset-Pipeline -Steps @($failStep) -OnFinish {
1625
+ param($failed, $exitCode, $output)
1626
+ $script:PipeResult = @{ Failed = $failed; Exit = $exitCode; Output = $output }
1627
+ }
1628
+ $deadline = (Get-Date).AddSeconds(60)
1629
+ while ($script:Pipe.Running -and (Get-Date) -lt $deadline) {
1630
+ Update-Pipeline
1631
+ Start-Sleep -Milliseconds 120
1632
+ }
1633
+ $failOk = $false
1634
+ $failDetail = 'pipeline did not finish'
1635
+ if ($script:PipeResult) {
1636
+ $failOk = $script:PipeResult.Failed -and ($script:PipeResult.Exit -eq 7)
1637
+ $failDetail = "failed=$($script:PipeResult.Failed) exit=$($script:PipeResult.Exit)"
1638
+ }
1639
+ Add-SelfTestResult 'pipeline_reports_failure' $failOk $failDetail
1640
+
1641
+ # 11. the files the wizard depends on are where it expects them
1642
+ Add-SelfTestResult 'console_installer_present' (Test-Path -LiteralPath $ConsoleInstaller) $ConsoleInstaller
1643
+ Add-SelfTestResult 'shell_installer_present' (Test-Path -LiteralPath $ShellInstaller) $ShellInstaller
1644
+
1645
+ # 12. the dry-run parser reads install.ps1's six lines positionally, and an
1646
+ # older five-line copy still parses (the node row is simply empty)
1647
+ $dryRunText = @(
1648
+ '[DRY-RUN] Python : C:\Python312\python.exe',
1649
+ '[DRY-RUN] websockets : installed',
1650
+ '[DRY-RUN] Domain : mesh.example.test',
1651
+ '[DRY-RUN] Config : C:\Users\probe\.config\antigravity-mesh',
1652
+ '[DRY-RUN] Autostart : C:\Users\probe\Startup\antigravity-agent.vbs',
1653
+ '[DRY-RUN] Node : offline (probe)'
1654
+ ) -join "`n"
1655
+ $facts = Get-PreflightFacts -Text $dryRunText
1656
+ $factsOk = ($facts.Python -eq 'C:\Python312\python.exe') -and
1657
+ ($facts.Websockets -eq 'installed') -and
1658
+ ($facts.Domain -eq 'mesh.example.test') -and
1659
+ ($facts.Config -eq 'C:\Users\probe\.config\antigravity-mesh') -and
1660
+ ($facts.Autostart -eq 'C:\Users\probe\Startup\antigravity-agent.vbs') -and
1661
+ ($facts.Node -eq 'offline (probe)')
1662
+ $legacyText = (($dryRunText -split "`n")[0..4] -join "`n")
1663
+ $factsOk = $factsOk -and ((Get-PreflightFacts -Text $legacyText).Node -eq '')
1664
+ Add-SelfTestResult 'preflight_parser' $factsOk ("domain='" + $facts.Domain + "' python='" + $facts.Python + "'")
1665
+
1666
+ # 13. "not configured" is detected without depending on the language
1667
+ $domainOk = (-not (Test-DomainConfigured -Value 'NOT CONFIGURED')) -and
1668
+ (-not (Test-DomainConfigured -Value '')) -and
1669
+ (-not (Test-DomainConfigured -Value '__MESH_DOMAIN__')) -and
1670
+ (Test-DomainConfigured -Value 'mesh.example.test')
1671
+ Add-SelfTestResult 'domain_configured_detection' $domainOk ''
1672
+
1673
+ # 14. the window chrome follows a runtime language switch, not only the page
1674
+ $chromeErrors = @()
1675
+ $savedChromeLang = $script:UiLang
1676
+ foreach ($code in @('en', 'ru')) {
1677
+ $script:UiLang = $code
1678
+ Show-Page -Index 2
1679
+ if ($script:BtnBack.Text -ne (Get-UiText -Key 'btn_back')) {
1680
+ $chromeErrors += "${code}: back='" + $script:BtnBack.Text + "'"
1681
+ }
1682
+ if ($script:BtnClose.Text -ne (Get-UiText -Key 'btn_close')) {
1683
+ $chromeErrors += "${code}: close='" + $script:BtnClose.Text + "'"
1684
+ }
1685
+ if ($script:BtnPrimary.Text -ne (Get-UiText -Key 'btn_install')) {
1686
+ $chromeErrors += "${code}: install='" + $script:BtnPrimary.Text + "'"
1687
+ }
1688
+ if ($script:StepLabel.Text -notmatch [regex]::Escape((Get-UiText -Key 'page_options_title')) -and
1689
+ $script:StepLabel.Text -notmatch '\d') {
1690
+ $chromeErrors += "${code}: step='" + $script:StepLabel.Text + "'"
1691
+ }
1692
+ }
1693
+ $script:UiLang = $savedChromeLang
1694
+ Add-SelfTestResult 'chrome_localised' ($chromeErrors.Count -eq 0) ($chromeErrors -join ' | ')
1695
+
1696
+ # 15. the result page must offer the Gemini address as a real link and a copy
1697
+ # button. It used to be a plain label, so it could be neither clicked nor
1698
+ # copied.
1699
+ $script:Install = @{
1700
+ Running = $false; Failed = $false; Exit = 0; Output = ''
1701
+ Url = 'https://mesh.example.test/sse?user=probe&token=tok'; Notice = ''; LinkNotice = ''
1702
+ }
1703
+ $script:Mode = 'quick'
1704
+ Show-Page -Index 4
1705
+ $linkFound = $false
1706
+ $copyButtonFound = $false
1707
+ $expectedCopy = Get-UiText -Key 'btn_copy_link'
1708
+ $pending = New-Object System.Collections.Stack
1709
+ foreach ($control in $script:Content.Controls) { $pending.Push($control) }
1710
+ while ($pending.Count -gt 0) {
1711
+ $control = $pending.Pop()
1712
+ if ($control -is [System.Windows.Forms.LinkLabel] -and $control.Text -eq $GeminiAppsUrl) {
1713
+ $linkFound = $true
1714
+ }
1715
+ if ($control -is [System.Windows.Forms.Button] -and $control.Text -eq $expectedCopy) {
1716
+ $copyButtonFound = $true
1717
+ }
1718
+ foreach ($child in $control.Controls) { $pending.Push($child) }
1719
+ }
1720
+ Add-SelfTestResult 'result_page_link' ($linkFound -and $copyButtonFound) `
1721
+ ("link=$linkFound copyButton=$copyButtonFound")
1722
+
1723
+ # 16. no control may be wider than the container it sits in, in any language:
1724
+ # a fixed-width button clipped the Russian labels.
1725
+ $overflow = @()
1726
+ $savedOverflowLang = $script:UiLang
1727
+ foreach ($code in @('en', 'ru')) {
1728
+ $script:UiLang = $code
1729
+ foreach ($index in @(0, 1, 2, 4)) {
1730
+ Show-Page -Index $index
1731
+ $walk = New-Object System.Collections.Stack
1732
+ foreach ($control in $script:Content.Controls) { $walk.Push($control) }
1733
+ while ($walk.Count -gt 0) {
1734
+ $control = $walk.Pop()
1735
+ foreach ($child in $control.Controls) {
1736
+ if ($child.Right -gt $control.ClientSize.Width) {
1737
+ $overflow += ("{0}/page{1}: {2} right={3} > {4}" -f `
1738
+ $code, $index, $child.GetType().Name, $child.Right, $control.ClientSize.Width)
1739
+ }
1740
+ $walk.Push($child)
1741
+ }
1742
+ }
1743
+ }
1744
+ }
1745
+ $script:UiLang = $savedOverflowLang
1746
+ Add-SelfTestResult 'no_control_overflow' ($overflow.Count -eq 0) (($overflow | Select-Object -First 5) -join ' | ')
1747
+
1748
+ # 17. the start language follows the user's language list. Checking
1749
+ # CurrentUICulture alone started this wizard in English on a Russian
1750
+ # Windows, because the process reported en-US while the display language
1751
+ # was ru-RU.
1752
+ $detectionErrors = @()
1753
+ if ((Select-Language -Codes @('ru-RU', 'en-US')) -ne 'ru') { $detectionErrors += 'ru-first' }
1754
+ if ((Select-Language -Codes @('en-US', 'ru-RU')) -ne 'en') { $detectionErrors += 'en-first' }
1755
+ if ((Select-Language -Codes @('ru')) -ne 'ru') { $detectionErrors += 'bare-ru' }
1756
+ if ((Select-Language -Codes @('de-DE')) -ne 'en') { $detectionErrors += 'unsupported' }
1757
+ if ((Select-Language -Codes @()) -ne 'en') { $detectionErrors += 'empty' }
1758
+ if ((Select-Language -Codes @('', 'ru-RU')) -ne 'ru') { $detectionErrors += 'blank-first' }
1759
+ $signals = @(Get-PreferredLanguageCodes)
1760
+ if ($signals.Count -eq 0) { $detectionErrors += 'no-signals' }
1761
+ Add-SelfTestResult 'language_detection' ($detectionErrors.Count -eq 0) `
1762
+ ("detected=" + (Get-DefaultLanguage) + " effective=" + $entryLang +
1763
+ " signals=[" + ($signals -join ',') + "] " + ($detectionErrors -join '|'))
1764
+
1765
+ # 18. the pipeline must not depend on %TEMP% being writable
1766
+ $scratch = Get-ScratchDirectory
1767
+ $scratchOk = $false
1768
+ $scratchDetail = 'dir=' + $scratch
1769
+ try {
1770
+ $scratchProbe = Join-Path $scratch ('scratch-probe-' + [Guid]::NewGuid().ToString('N') + '.tmp')
1771
+ Set-Content -LiteralPath $scratchProbe -Value 'x' -Encoding ASCII -ErrorAction Stop
1772
+ Remove-Item -LiteralPath $scratchProbe -Force -ErrorAction Stop
1773
+ $scratchOk = $true
1774
+ } catch {
1775
+ $scratchDetail += ' error=' + $_.Exception.Message
1776
+ }
1777
+ Add-SelfTestResult 'scratch_directory' $scratchOk $scratchDetail
1778
+
1779
+ $failedCount = @($script:SelfTestResults | Where-Object { -not $_.pass }).Count
1780
+ return [pscustomobject]@{
1781
+ ok = ($failedCount -eq 0)
1782
+ passed = @($script:SelfTestResults | Where-Object { $_.pass }).Count
1783
+ failed = $failedCount
1784
+ results = $script:SelfTestResults
1785
+ }
1786
+ }
1787
+
1788
+ # ------------------------------------------------------------------------------
1789
+ # Main
1790
+ # ------------------------------------------------------------------------------
1791
+
1792
+ try {
1793
+ Initialize-Strings
1794
+ } catch {
1795
+ $message = $_.Exception.Message
1796
+ $text = $message
1797
+ if ($message.StartsWith('__STRINGS_MISSING__')) {
1798
+ $text = 'Language file not found: ' + $message.Substring(19)
1799
+ } elseif ($message.StartsWith('__STRINGS_INVALID__')) {
1800
+ $text = 'Language file is not valid JSON: ' + $message.Substring(19)
1801
+ } elseif ($message.StartsWith('__LANG_MISSING__')) {
1802
+ $text = 'Language is missing from the language file: ' + $message.Substring(16)
1803
+ }
1804
+ [Console]::Error.WriteLine($text)
1805
+ if (-not $SelfTest) {
1806
+ [void][System.Windows.Forms.MessageBox]::Show($text, 'Antigravity Mesh')
1807
+ }
1808
+ exit 1
1809
+ }
1810
+
1811
+ if ($Lang) {
1812
+ # An explicit -Lang always wins over detection.
1813
+ $script:UiLang = $Lang
1814
+ } else {
1815
+ $script:UiLang = Get-DefaultLanguage
1816
+ }
1817
+
1818
+
1819
+ if ($SelfTest) {
1820
+ $report = Invoke-SelfTest
1821
+ $report | ConvertTo-Json -Depth 6
1822
+ if ($report.ok) { exit 0 }
1823
+ exit 1
1824
+ }
1825
+
1826
+ if ($RunInstall) {
1827
+ $script:Mode = $InstallMode
1828
+ $script:Node = $InstallUser
1829
+ $script:Domain = $InstallGateway
1830
+ $script:Token = $InstallToken
1831
+ $script:SshTarget = $InstallSshTarget
1832
+ $script:SshPort = $InstallSshPort
1833
+ $script:Headless = $true
1834
+
1835
+ if (-not (Test-InstallOptions)) {
1836
+ Write-Output ('INSTALL_REFUSED ' + $script:OptionError)
1837
+ exit 2
1838
+ }
1839
+
1840
+ if ($script:Mode -eq 'ssh') {
1841
+ $steps = New-SshSteps -Target "$($script:SshTarget)".Trim() -Port $script:SshPort `
1842
+ -Domain "$($script:Domain)".Trim()
1843
+ } else {
1844
+ $steps = New-ConsoleInstallerSteps -Mode $script:Mode -Node "$($script:Node)".Trim() `
1845
+ -Domain "$($script:Domain)".Trim() -Token "$($script:Token)".Trim()
1846
+ }
1847
+
1848
+ $script:PipeResult = $null
1849
+ Reset-Pipeline -Steps $steps -OnFinish {
1850
+ param($failed, $exitCode, $output)
1851
+ $script:PipeResult = @{ Failed = $failed; Exit = $exitCode; Output = $output }
1852
+ }
1853
+
1854
+ $deadline = (Get-Date).AddMinutes(15)
1855
+ while ($script:Pipe.Running -and (Get-Date) -lt $deadline) {
1856
+ Update-Pipeline
1857
+ Start-Sleep -Milliseconds 250
1858
+ }
1859
+
1860
+ $failed = $true
1861
+ $code = 1
1862
+ $output = ''
1863
+ if ($script:PipeResult) {
1864
+ $failed = [bool]$script:PipeResult.Failed
1865
+ $code = $script:PipeResult.Exit
1866
+ $output = [string]$script:PipeResult.Output
1867
+ }
1868
+ Write-Output $output
1869
+ if ($failed) {
1870
+ Write-Output ('INSTALL_FAILED exit=' + $code)
1871
+ exit 1
1872
+ }
1873
+ $url = Get-ResultUrl -Text $output -Mode $script:Mode
1874
+ Write-Output ('MESH_URL=' + $url)
1875
+ exit 0
1876
+ }
1877
+
1878
+ $script:Device = Get-DeviceInfo
1879
+ Initialize-Form
1880
+ Show-Page -Index 0
1881
+ Start-Preflight
1882
+ [void]$script:Form.ShowDialog()