<# claude-proxy-gui.ps1 - GUI "рубильник" for forcing Claude desktop traffic through a proxy. Claude-styled: warm cream surface, coral accent, rounded cards/buttons, spark mark. Self-elevates once at startup so firewall toggling needs no repeated UAC prompts. #> $ErrorActionPreference = 'Stop' $Root = Split-Path -Parent $MyInvocation.MyCommand.Path . (Join-Path $Root 'claude-proxy.lib.ps1') # ---- elevated firewall helper ------------------------------------------------- # The GUI itself runs DE-ELEVATED, so the Claude it launches is the user's normal instance # (the one pinned in the tray) and not a separate admin instance. Firewall changes need admin, # so the GUI relaunches this exe with --fw-apply / --fw-remove via RunAs; those do only the # firewall op (no GUI) and exit. # NB: use dash-less tokens ('fwapply'/'fwremove'). ps2exe mangles '--fw-apply' into a bound # parameter, so it would never reach $args as a plain string. if ($args -contains 'fwapply' -or $args -contains 'fwremove') { if (Test-Admin) { try { if ($args -contains 'fwapply') { Apply-FirewallForConfig } else { Remove-Firewall -Quiet } } catch { } } return } Add-Type -AssemblyName System.Windows.Forms Add-Type -AssemblyName System.Drawing [System.Windows.Forms.Application]::EnableVisualStyles() [System.Windows.Forms.Application]::SetCompatibleTextRenderingDefault($false) # Keep the app alive on a stray UI-thread error and show a tidy message (no raw .NET crash dialog). [System.Windows.Forms.Application]::SetUnhandledExceptionMode([System.Windows.Forms.UnhandledExceptionMode]::CatchException) [System.Windows.Forms.Application]::add_ThreadException({ param($s, $e) try { [System.Windows.Forms.MessageBox]::Show($e.Exception.Message, 'Ошибка', 'OK', 'Warning') | Out-Null } catch {} }) # ---- Claude palette ---------------------------------------------------------- function C([int]$r, [int]$g, [int]$b) { [System.Drawing.Color]::FromArgb($r, $g, $b) } $cCream = C 250 249 245 $cInk = C 41 40 36 $cHead = C 26 25 21 $cMuted = C 131 129 123 $cCard = C 255 255 255 $cBorder = C 232 229 220 $cCoral = C 193 95 60 $cCoralLo = C 224 137 101 $cCoralHi = C 168 79 48 $cTrackOff = C 206 202 192 $cOk = C 74 122 80 $cRed = C 196 74 61 $cWhite = C 255 255 255 $fReg = New-Object System.Drawing.Font('Segoe UI', 9.5) $fSemi = New-Object System.Drawing.Font('Segoe UI Semibold', 9.5) $fHead = New-Object System.Drawing.Font('Segoe UI Semibold', 15) $fSub = New-Object System.Drawing.Font('Segoe UI', 9) $fPill = New-Object System.Drawing.Font('Segoe UI Semibold', 12) # ---- helpers ----------------------------------------------------------------- function RoundPath($w, $h, $r) { $d = $r * 2 $p = New-Object System.Drawing.Drawing2D.GraphicsPath $p.AddArc(0, 0, $d, $d, 180, 90) $p.AddArc($w - $d, 0, $d, $d, 270, 90) $p.AddArc($w - $d, $h - $d, $d, $d, 0, 90) $p.AddArc(0, $h - $d, $d, $d, 90, 90) $p.CloseFigure(); $p } function Set-Round($ctl, $r) { $ctl.Region = New-Object System.Drawing.Region((RoundPath $ctl.Width $ctl.Height $r)) } # Enable double buffering on any control (kills repaint flicker on custom-painted panels). $script:DBProp = [System.Windows.Forms.Control].GetProperty('DoubleBuffered', [System.Reflection.BindingFlags]([System.Reflection.BindingFlags]::Instance -bor [System.Reflection.BindingFlags]::NonPublic)) function Set-DoubleBuffered($ctl) { $script:DBProp.SetValue($ctl, $true, $null) } function Draw-Spark($g, $cx, $cy, $inner, $outer, $penW, $col) { $pen = New-Object System.Drawing.Pen($col, $penW) $pen.StartCap = 'Round'; $pen.EndCap = 'Round' for ($i = 0; $i -lt 12; $i++) { $a = [math]::PI * 2 * $i / 12 $len = $outer * (0.82 + 0.18 * [math]::Abs([math]::Cos($a * 1.5))) $g.DrawLine($pen, ($cx + [math]::Cos($a) * $inner), ($cy + [math]::Sin($a) * $inner), ($cx + [math]::Cos($a) * $len), ($cy + [math]::Sin($a) * $len)) } $cd = $penW * 1.35 $db = New-Object System.Drawing.SolidBrush($col) $g.FillEllipse($db, ($cx - $cd), ($cy - $cd), ($cd * 2), ($cd * 2)) $pen.Dispose(); $db.Dispose() } # card factory (white rounded panel with a hairline border) function New-Card($x, $y, $w, $h) { $p = New-Object System.Windows.Forms.Panel $p.Location = New-Object System.Drawing.Point($x, $y) $p.Size = New-Object System.Drawing.Size($w, $h) $p.BackColor = $cCream Set-DoubleBuffered $p $p.Add_Paint({ param($s, $e) $e.Graphics.SmoothingMode = 'AntiAlias' $path = RoundPath ($s.Width - 1) ($s.Height - 1) 14 $b = New-Object System.Drawing.SolidBrush($cCard) $pen = New-Object System.Drawing.Pen($cBorder, 1) $e.Graphics.FillPath($b, $path); $e.Graphics.DrawPath($pen, $path) $b.Dispose(); $pen.Dispose(); $path.Dispose() }) $p } function Style-Primary($btn) { $btn.FlatStyle = 'Flat'; $btn.FlatAppearance.BorderSize = 0 $btn.BackColor = $cCoral; $btn.ForeColor = $cWhite; $btn.Font = $fSemi $btn.FlatAppearance.MouseOverBackColor = $cCoralHi $btn.Cursor = [System.Windows.Forms.Cursors]::Hand # re-round after a handle recreation; $this is the button (do NOT capture $btn - it is null here) $btn.Add_HandleCreated({ if ($this) { $this.Region = New-Object System.Drawing.Region((RoundPath $this.Width $this.Height 9)) } }) Set-Round $btn 9 } function Style-Ghost($btn) { $btn.FlatStyle = 'Flat'; $btn.FlatAppearance.BorderColor = $cBorder; $btn.FlatAppearance.BorderSize = 1 $btn.BackColor = $cCard; $btn.ForeColor = $cInk; $btn.Font = $fReg $btn.FlatAppearance.MouseOverBackColor = $cCream $btn.Cursor = [System.Windows.Forms.Cursors]::Hand Set-Round $btn 9 } # ---- form -------------------------------------------------------------------- $form = New-Object System.Windows.Forms.Form $form.Text = 'Claude Proxy' $form.ClientSize = New-Object System.Drawing.Size(452, 512) $form.StartPosition = 'CenterScreen' $form.FormBorderStyle = 'FixedSingle' $form.MaximizeBox = $false $form.BackColor = $cCream $form.Font = $fReg $form.ForeColor = $cInk Set-DoubleBuffered $form try { $entry = [System.Reflection.Assembly]::GetEntryAssembly() if ($entry -and $entry.Location -like '*.exe') { $form.Icon = [System.Drawing.Icon]::ExtractAssociatedIcon($entry.Location) } elseif (Test-Path (Join-Path $Root 'icon.ico')) { $form.Icon = New-Object System.Drawing.Icon((Join-Path $Root 'icon.ico')) } } catch {} # header mark (coral tile + spark) $mark = New-Object System.Windows.Forms.Panel $mark.Size = New-Object System.Drawing.Size(46, 46) $mark.Location = New-Object System.Drawing.Point(24, 22) $mark.BackColor = $cCream Set-DoubleBuffered $mark $mark.Add_Paint({ param($s, $e) $g = $e.Graphics; $g.SmoothingMode = 'AntiAlias' $path = RoundPath ($s.Width - 1) ($s.Height - 1) 12 $rect = New-Object System.Drawing.Rectangle(0, 0, $s.Width, $s.Height) $br = New-Object System.Drawing.Drawing2D.LinearGradientBrush($rect, $cCoralLo, $cCoralHi, 90) $g.FillPath($br, $path) # toggle glyph (matches the app icon) $S = $s.Width $tw = $S * 0.58; $th = $S * 0.34; $tx = ($S - $tw) / 2.0; $ty = ($S - $th) / 2.0 $tp = New-Object System.Drawing.Drawing2D.GraphicsPath $tp.AddArc($tx, $ty, $th, $th, 90, 180); $tp.AddArc($tx + $tw - $th, $ty, $th, $th, 270, 180); $tp.CloseFigure() $tb = New-Object System.Drawing.SolidBrush((C 248 246 239)); $g.FillPath($tb, $tp) $kd = $th - ($S * 0.10); $kx = $tx + $tw - $kd - ($S * 0.05); $ky = $ty + (($th - $kd) / 2.0) $kb = New-Object System.Drawing.SolidBrush($cCoralHi); $g.FillEllipse($kb, $kx, $ky, $kd, $kd) $tb.Dispose(); $kb.Dispose(); $tp.Dispose(); $br.Dispose(); $path.Dispose() }) $form.Controls.Add($mark) $title = New-Object System.Windows.Forms.Label $title.Text = 'Claude Proxy'; $title.Font = $fHead; $title.ForeColor = $cHead $title.AutoSize = $true; $title.Location = New-Object System.Drawing.Point(82, 22) $form.Controls.Add($title) $subtitle = New-Object System.Windows.Forms.Label $subtitle.Text = 'весь трафик Claude через прокси'; $subtitle.Font = $fSub; $subtitle.ForeColor = $cMuted $subtitle.AutoSize = $false; $subtitle.Location = New-Object System.Drawing.Point(84, 48) $subtitle.Size = New-Object System.Drawing.Size(196, 18) # keep clear of the mode radios on the right $form.Controls.Add($subtitle) # mode selector (top-right of the header) - right-aligned so it never overlaps the subtitle $rbProxy = New-Object System.Windows.Forms.RadioButton $rbProxy.Text = 'Прямой прокси'; $rbProxy.ForeColor = $cInk; $rbProxy.Font = $fReg; $rbProxy.AutoSize = $false $rbProxy.TextAlign = 'MiddleLeft'; $rbProxy.BackColor = $cCream $rbProxy.Size = New-Object System.Drawing.Size(150, 22); $rbProxy.Location = New-Object System.Drawing.Point(292, 18) $form.Controls.Add($rbProxy) $rbVless = New-Object System.Windows.Forms.RadioButton $rbVless.Text = 'VLESS + Reality'; $rbVless.ForeColor = $cInk; $rbVless.Font = $fReg; $rbVless.AutoSize = $false $rbVless.TextAlign = 'MiddleLeft'; $rbVless.BackColor = $cCream $rbVless.Size = New-Object System.Drawing.Size(150, 22); $rbVless.Location = New-Object System.Drawing.Point(292, 42) $form.Controls.Add($rbVless) # ---- painted toggle ---------------------------------------------------------- $script:SwitchOn = $false $script:Busy = $false $tgl = New-Object System.Windows.Forms.Panel $tgl.Size = New-Object System.Drawing.Size(172, 58) $tgl.Location = New-Object System.Drawing.Point(24, 88) $tgl.BackColor = $cCream $tgl.Cursor = [System.Windows.Forms.Cursors]::Hand Set-DoubleBuffered $tgl $form.Controls.Add($tgl) $tgl.Add_Paint({ param($s, $e) $g = $e.Graphics; $g.SmoothingMode = 'AntiAlias' $w = $s.Width; $h = $s.Height $path = RoundPath $w $h ($h / 2) if ($script:SwitchOn) { $rect = New-Object System.Drawing.Rectangle(0, 0, $w, $h) $br = New-Object System.Drawing.Drawing2D.LinearGradientBrush($rect, $cCoralLo, $cCoral, 0) } else { $br = New-Object System.Drawing.SolidBrush($cTrackOff) } $g.FillPath($br, $path) $knobD = $h - 12 $knobX = if ($script:SwitchOn) { $w - $knobD - 6 } else { 6 } $shadow = New-Object System.Drawing.SolidBrush([System.Drawing.Color]::FromArgb(40, 0, 0, 0)) $g.FillEllipse($shadow, ($knobX + 1), 7, $knobD, $knobD) $kb = New-Object System.Drawing.SolidBrush($cWhite) $g.FillEllipse($kb, $knobX, 6, $knobD, $knobD) $txt = if ($script:SwitchOn) { 'ВКЛ' } else { 'ВЫКЛ' } $tcol = if ($script:SwitchOn) { $cWhite } else { C 106 104 98 } $tb = New-Object System.Drawing.SolidBrush($tcol) $tx = if ($script:SwitchOn) { 22 } else { 66 } $g.DrawString($txt, $fPill, $tb, $tx, 16) $br.Dispose(); $kb.Dispose(); $tb.Dispose(); $shadow.Dispose(); $path.Dispose() }) $hint = New-Object System.Windows.Forms.Label $hint.Text = 'Включено = наружу только через прокси, всё остальное заблокировано' $hint.ForeColor = $cMuted; $hint.Font = $fSub; $hint.AutoSize = $false $hint.Location = New-Object System.Drawing.Point(208, 92) $hint.Size = New-Object System.Drawing.Size(224, 52) $form.Controls.Add($hint) # ---- status card ------------------------------------------------------------- $card = New-Card 20 162 412 158 $form.Controls.Add($card) $lblProxyCap = New-Object System.Windows.Forms.Label $lblProxyCap.Text = 'Прокси'; $lblProxyCap.ForeColor = $cMuted; $lblProxyCap.AutoSize = $true $lblProxyCap.Location = New-Object System.Drawing.Point(18, 18); $card.Controls.Add($lblProxyCap) $txtProxy = New-Object System.Windows.Forms.TextBox $txtProxy.Location = New-Object System.Drawing.Point(84, 15); $txtProxy.Size = New-Object System.Drawing.Size(214, 24) $txtProxy.BorderStyle = 'FixedSingle'; $txtProxy.BackColor = $cCream; $txtProxy.ForeColor = $cInk $card.Controls.Add($txtProxy) $btnSave = New-Object System.Windows.Forms.Button $btnSave.Text = 'Сохранить'; $btnSave.Location = New-Object System.Drawing.Point(308, 13); $btnSave.Size = New-Object System.Drawing.Size(88, 28) Style-Ghost $btnSave; $card.Controls.Add($btnSave) $rProxyDot = New-Object System.Windows.Forms.Label $rProxyDot.Text = [char]0x25CF; $rProxyDot.AutoSize = $true; $rProxyDot.Font = New-Object System.Drawing.Font('Segoe UI', 11) $rProxyDot.ForeColor = $cMuted; $rProxyDot.Location = New-Object System.Drawing.Point(17, 54); $card.Controls.Add($rProxyDot) $lblReach = New-Object System.Windows.Forms.Label $lblReach.AutoSize = $true; $lblReach.ForeColor = $cInk; $lblReach.Font = $fReg $lblReach.Location = New-Object System.Drawing.Point(38, 56); $card.Controls.Add($lblReach) $sep = New-Object System.Windows.Forms.Label $sep.AutoSize = $false; $sep.Location = New-Object System.Drawing.Point(18, 86); $sep.Size = New-Object System.Drawing.Size(378, 1) $sep.BackColor = $cBorder; $card.Controls.Add($sep) $lblFwCap = New-Object System.Windows.Forms.Label $lblFwCap.Text = 'Firewall-лок'; $lblFwCap.ForeColor = $cMuted; $lblFwCap.AutoSize = $true $lblFwCap.Location = New-Object System.Drawing.Point(18, 98); $card.Controls.Add($lblFwCap) $lblFw = New-Object System.Windows.Forms.Label $lblFw.AutoSize = $true; $lblFw.ForeColor = $cInk; $lblFw.Font = $fSemi $lblFw.Location = New-Object System.Drawing.Point(120, 98); $card.Controls.Add($lblFw) $lblClCap = New-Object System.Windows.Forms.Label $lblClCap.Text = 'Claude'; $lblClCap.ForeColor = $cMuted; $lblClCap.AutoSize = $true $lblClCap.Location = New-Object System.Drawing.Point(18, 122); $card.Controls.Add($lblClCap) $lblCl = New-Object System.Windows.Forms.Label $lblCl.AutoSize = $true; $lblCl.ForeColor = $cInk; $lblCl.Font = $fSemi $lblCl.Location = New-Object System.Drawing.Point(120, 122); $card.Controls.Add($lblCl) # VLESS-only: Xray status row (positioned by Apply-ModeLayout) $lblXrayCap = New-Object System.Windows.Forms.Label $lblXrayCap.Text = 'Xray-core'; $lblXrayCap.ForeColor = $cMuted; $lblXrayCap.AutoSize = $true $lblXrayCap.Location = New-Object System.Drawing.Point(18, 68); $card.Controls.Add($lblXrayCap) $lblXray = New-Object System.Windows.Forms.Label $lblXray.AutoSize = $true; $lblXray.Font = $fSemi $lblXray.Location = New-Object System.Drawing.Point(120, 68); $card.Controls.Add($lblXray) $btnXray = New-Object System.Windows.Forms.Button $btnXray.Text = 'Установить'; $btnXray.Location = New-Object System.Drawing.Point(300, 64); $btnXray.Size = New-Object System.Drawing.Size(96, 26) Style-Ghost $btnXray; $card.Controls.Add($btnXray) # ---- options ----------------------------------------------------------------- $chkRestart = New-Object System.Windows.Forms.CheckBox $chkRestart.Text = 'Перезапускать Claude при переключении' $chkRestart.ForeColor = $cInk; $chkRestart.AutoSize = $true; $chkRestart.Checked = $true; $chkRestart.Font = $fReg $chkRestart.Location = New-Object System.Drawing.Point(22, 324); $form.Controls.Add($chkRestart) $chkFailClosed = New-Object System.Windows.Forms.CheckBox $chkFailClosed.Text = 'Fail-closed: блокировать любой трафик мимо прокси (firewall)' $chkFailClosed.ForeColor = $cInk; $chkFailClosed.AutoSize = $true; $chkFailClosed.Checked = $true; $chkFailClosed.Font = $fReg $chkFailClosed.Location = New-Object System.Drawing.Point(22, 348); $form.Controls.Add($chkFailClosed) # ---- action buttons ---------------------------------------------------------- $btnLaunch = New-Object System.Windows.Forms.Button $btnLaunch.Text = 'Запустить Claude'; $btnLaunch.Location = New-Object System.Drawing.Point(20, 384); $btnLaunch.Size = New-Object System.Drawing.Size(170, 36) Style-Primary $btnLaunch; $form.Controls.Add($btnLaunch) $btnVerify = New-Object System.Windows.Forms.Button $btnVerify.Text = 'Проверить'; $btnVerify.Location = New-Object System.Drawing.Point(198, 386); $btnVerify.Size = New-Object System.Drawing.Size(142, 32) Style-Ghost $btnVerify; $form.Controls.Add($btnVerify) $btnRefresh = New-Object System.Windows.Forms.Button $btnRefresh.Text = 'Обновить'; $btnRefresh.Location = New-Object System.Drawing.Point(348, 386); $btnRefresh.Size = New-Object System.Drawing.Size(84, 32) Style-Ghost $btnRefresh; $form.Controls.Add($btnRefresh) # ---- log --------------------------------------------------------------------- $log = New-Object System.Windows.Forms.TextBox $log.Multiline = $true; $log.ReadOnly = $true; $log.ScrollBars = 'Vertical' $log.Location = New-Object System.Drawing.Point(20, 430); $log.Size = New-Object System.Drawing.Size(412, 66) $log.BorderStyle = 'FixedSingle'; $log.BackColor = $cCard; $log.ForeColor = $cMuted; $log.Font = New-Object System.Drawing.Font('Consolas', 8.5) $form.Controls.Add($log) function Write-Log([string]$msg) { $log.AppendText(((Get-Date).ToString('HH:mm:ss') + ' ' + $msg + "`r`n")) } function Get-CurrentMode { if ($rbVless.Checked) { 'vless' } else { 'proxy' } } function Save-CurrentConfig { $cfg = Get-Config $cfg.mode = Get-CurrentMode if ($cfg.mode -eq 'vless') { $cfg.vless = $txtProxy.Text } else { $cfg.proxy = $txtProxy.Text } $cfg.failClosed = $chkFailClosed.Checked Save-Config $cfg $cfg } function Update-XrayStatus { if (Test-XrayPresent) { $lblXray.Text = "готов ($script:XrayVersion)"; $lblXray.ForeColor = $cOk; $btnXray.Text = 'Переустановить' } elseif ($script:XrayEmbedB64) { $lblXray.Text = "встроен ($script:XrayVersion)"; $lblXray.ForeColor = $cOk; $btnXray.Text = 'Распаковать' } else { $lblXray.Text = 'не установлен'; $lblXray.ForeColor = $cRed; $btnXray.Text = 'Установить' } } # Swap the card between proxy and VLESS layouts. function Apply-ModeLayout($mode) { $cfg = Get-Config if ($mode -eq 'vless') { $lblProxyCap.Text = 'VLESS'; $subtitle.Text = 'через VLESS + Reality тоннель' $txtProxy.UseSystemPasswordChar = $true; $txtProxy.Text = [string]$cfg.vless $lblXrayCap.Visible = $true; $lblXray.Visible = $true; $btnXray.Visible = $true $sep.Top = 100; $lblFwCap.Top = 112; $lblFw.Top = 112; $lblClCap.Top = 134; $lblCl.Top = 134 Update-XrayStatus } else { $lblProxyCap.Text = 'Прокси'; $subtitle.Text = 'весь трафик Claude через прокси' $txtProxy.UseSystemPasswordChar = $false; $txtProxy.Text = [string]$cfg.proxy $lblXrayCap.Visible = $false; $lblXray.Visible = $false; $btnXray.Visible = $false $sep.Top = 74; $lblFwCap.Top = 86; $lblFw.Top = 86; $lblClCap.Top = 108; $lblCl.Top = 108 } } # Mode-aware pre-flight before enabling: validate input, ensure Xray, check reachability, save. function Preflight-Enable { if ((Get-CurrentMode) -eq 'vless') { if ([string]::IsNullOrWhiteSpace($txtProxy.Text)) { [System.Windows.Forms.MessageBox]::Show('Вставь VLESS-ссылку из 3x-ui.', 'Нужна ссылка', 'OK', 'Warning') | Out-Null; return $false } try { $v = ConvertFrom-VlessLink $txtProxy.Text } catch { [System.Windows.Forms.MessageBox]::Show($_.Exception.Message, 'Некорректная ссылка', 'OK', 'Error') | Out-Null; return $false } if (-not (Test-XrayPresent)) { Set-Busy $true try { Ensure-Xray ${function:Write-Log}; Update-XrayStatus } catch { Write-Log ('ОШИБКА подготовки Xray: ' + $_.Exception.Message); [System.Windows.Forms.MessageBox]::Show($_.Exception.Message, 'Ошибка Xray', 'OK', 'Error') | Out-Null; Set-Busy $false; return $false } Set-Busy $false } if (-not (Test-ProxyReachable ([PSCustomObject]@{ PHost = $v.VHost; Port = [int]$v.Port }))) { $r = [System.Windows.Forms.MessageBox]::Show("Сервер $($v.VHost):$($v.Port) не отвечает. Всё равно продолжить?", 'Сервер недоступен', 'YesNo', 'Warning') if ($r -ne 'Yes') { return $false } } } else { try { $p = Parse-Proxy $txtProxy.Text } catch { [System.Windows.Forms.MessageBox]::Show($_.Exception.Message, 'Некорректный адрес', 'OK', 'Error') | Out-Null; return $false } if (-not (Test-ProxyReachable $p)) { $r = [System.Windows.Forms.MessageBox]::Show("Прокси $($p.PHost):$($p.Port) не отвечает. Всё равно включить?", 'Прокси недоступен', 'YesNo', 'Warning') if ($r -ne 'Yes') { return $false } } } Save-CurrentConfig | Out-Null return $true } # Relaunch this exe (or script in dev) elevated to apply/remove the firewall. Returns $false if # the user cancels the UAC prompt. function Invoke-ElevatedSelf([string]$fwArg) { try { $self = ([System.Diagnostics.Process]::GetCurrentProcess()).MainModule.FileName if ($self -like '*powershell*' -or $self -like '*pwsh*') { Start-Process -FilePath $self -Verb RunAs -Wait -WindowStyle Hidden -ArgumentList @( '-NoProfile', '-ExecutionPolicy', 'Bypass', '-STA', '-File', "`"$PSCommandPath`"", $fwArg) } else { Start-Process -FilePath $self -Verb RunAs -Wait -ArgumentList $fwArg } return $true } catch { return $false } # UAC cancelled / error } # Enable: start tunnel (vless) + firewall (elevated helper) + launch Claude (de-elevated). function Do-Enable([bool]$restart) { $cfg = Get-Config $mode = if ($cfg.mode) { $cfg.mode } else { 'proxy' } if ($mode -eq 'vless') { $localPort = if ($cfg.localPort) { [int]$cfg.localPort } else { 10808 } Ensure-Xray ${function:Write-Log} Start-Xray $cfg.vless $localPort $proxyArg = "socks5://127.0.0.1:$localPort" } else { $proxyArg = (Parse-Proxy $cfg.proxy).Arg } if ($cfg.failClosed) { if (-not (Invoke-ElevatedSelf 'fwapply')) { if ($mode -eq 'vless') { Stop-Xray } throw 'Установка firewall отменена в UAC.' } } if ($restart) { $app = Get-ClaudeApp Stop-ClaudeInstances $app $a = @("--proxy-server=$proxyArg"); if ($cfg.extraArgs) { $a += $cfg.extraArgs } Start-ClaudeApp $app ($a -join ' ') | Out-Null } } # Disable: remove firewall (elevated helper) + stop tunnel + relaunch Claude direct. function Do-Disable([bool]$restart) { $app = Get-ClaudeApp if ((Get-FirewallState $app) -ne 'absent') { if (-not (Invoke-ElevatedSelf 'fwremove')) { throw 'Снятие firewall отменено в UAC (лок остался).' } } Stop-Xray if ($restart) { Stop-ClaudeInstances $app; Start-ClaudeApp $app '' | Out-Null } } # ---- state / UI update ------------------------------------------------------- function Update-Ui([switch]$CheckReachable) { try { $app = Get-ClaudeApp $fw = Get-FirewallState $app $cl = Get-ClaudeStatus $app $newOn = ($fw -eq 'ok') if ($newOn -ne $script:SwitchOn) { $script:SwitchOn = $newOn; $tgl.Invalidate() } $lblFw.Text = switch ($fw) { 'ok' { 'установлен' } 'stale' { 'устарел (путь изменился)' } default { 'нет' } } $lblFw.ForeColor = if ($fw -eq 'ok') { $cCoral } elseif ($fw -eq 'stale') { $cRed } else { $cMuted } if (-not $cl.Running) { $lblCl.Text = 'не запущен'; $lblCl.ForeColor = $cMuted } elseif ($cl.Proxied) { $lblCl.Text = 'через прокси'; $lblCl.ForeColor = $cOk } else { $lblCl.Text = 'напрямую'; $lblCl.ForeColor = $cRed } if ($CheckReachable) { $ok = $false; $ep = '' if ((Get-CurrentMode) -eq 'vless') { try { $v = ConvertFrom-VlessLink $txtProxy.Text; $ep = "$($v.VHost):$($v.Port)"; $ok = Test-ProxyReachable ([PSCustomObject]@{ PHost = $v.VHost; Port = [int]$v.Port }) } catch { $ep = 'некорректная ссылка' } } else { try { $p = Parse-Proxy $txtProxy.Text; $ep = "$($p.PHost):$($p.Port)"; $ok = Test-ProxyReachable $p } catch { $ep = 'некорректный адрес' } } $rProxyDot.ForeColor = if ($ok) { $cOk } else { $cRed } $lblReach.Text = if ($ok) { "сервер доступен $ep" } else { "не отвечает $ep" } } } catch { Write-Log ('ошибка обновления: ' + $_.Exception.Message) } } function Set-Busy([bool]$b) { # Do NOT toggle $tgl.Enabled here: that forces a repaint and causes the flicker. # The $script:Busy flag already guards re-entry. $script:Busy = $b $btnSave.Enabled = -not $b; $btnRefresh.Enabled = -not $b; $btnVerify.Enabled = -not $b; $btnLaunch.Enabled = -not $b $form.Cursor = if ($b) { [System.Windows.Forms.Cursors]::WaitCursor } else { [System.Windows.Forms.Cursors]::Default } } # ---- actions ----------------------------------------------------------------- function Toggle-Switch { if ($script:Busy) { return } $cfg = Get-Config $restart = $chkRestart.Checked try { if (-not $script:SwitchOn) { if (-not (Preflight-Enable)) { return } $running = (Get-ClaudeStatus (Get-ClaudeApp)).Running # Only restart (kill+relaunch) a running Claude — and only if the user allows it. $doRestart = $false if ($running) { if ($restart) { $r = [System.Windows.Forms.MessageBox]::Show( 'Claude запущен. Перезапустить его через прокси? (текущее окно закроется)', 'Перезапуск Claude', 'OKCancel', 'Question') if ($r -ne 'OK') { return } $doRestart = $true } } else { # Claude closed: just launch it already-proxied — no reboot. $doRestart = $true } Set-Busy $true Write-Log 'включаю...' Do-Enable $doRestart $note = if (-not $running) { 'Claude запущен через тоннель/прокси.' } elseif ($doRestart) { 'Claude перезапущен.' } else { 'лок установлен. Claude ещё напрямую — перезапусти, чтобы применить.' } Write-Log ('ВКЛ. ' + $note) } else { $restartOff = $restart if ($restartOff) { $r = [System.Windows.Forms.MessageBox]::Show( 'Выключение перезапустит Claude напрямую (текущее окно закроется). Продолжить?', 'Перезапуск Claude', 'OKCancel', 'Question') if ($r -ne 'OK') { $restartOff = $false } } Set-Busy $true Write-Log 'выключаю прокси...' Do-Disable $restartOff Write-Log 'прокси ВЫКЛ. firewall-лок снят.' } } catch { Write-Log ('ОШИБКА: ' + $_.Exception.Message) [System.Windows.Forms.MessageBox]::Show($_.Exception.Message, 'Ошибка', 'OK', 'Error') | Out-Null } finally { Set-Busy $false Update-Ui -CheckReachable } } # Launch Claude through the proxy on demand — installs the lock (if fail-closed) and # starts Claude already-proxied. Works whether Claude is open (restart) or closed (fresh start). function Launch-Proxied { if ($script:Busy) { return } try { if (-not (Preflight-Enable)) { return } if ((Get-ClaudeStatus (Get-ClaudeApp)).Running) { $r = [System.Windows.Forms.MessageBox]::Show( 'Claude уже запущен. Перезапустить его через прокси? (текущее окно закроется)', 'Перезапуск Claude', 'OKCancel', 'Question') if ($r -ne 'OK') { return } } Set-Busy $true Write-Log 'запускаю Claude через прокси...' Do-Enable $true Write-Log 'Claude запущен через прокси.' } catch { Write-Log ('ОШИБКА: ' + $_.Exception.Message) [System.Windows.Forms.MessageBox]::Show($_.Exception.Message, 'Ошибка', 'OK', 'Error') | Out-Null } finally { Set-Busy $false; Update-Ui -CheckReachable } } $tgl.Add_Click({ Toggle-Switch }) $btnLaunch.Add_Click({ Launch-Proxied }) $btnRefresh.Add_Click({ Update-Ui -CheckReachable; Write-Log 'статус обновлён.' }) $btnVerify.Add_Click({ try { $r = Invoke-Verify $r.Lines | ForEach-Object { Write-Log $_ } Write-Log "лог: $($r.LogPath)" $ico = if ($r.Ok) { 'Information' } elseif ($r.Verdict -like 'ПРОВАЛ*') { 'Error' } else { 'Warning' } [System.Windows.Forms.MessageBox]::Show($r.Verdict, 'Результат проверки', 'OK', $ico) | Out-Null } catch { [System.Windows.Forms.MessageBox]::Show($_.Exception.Message, 'Ошибка', 'OK', 'Error') | Out-Null } }) $btnSave.Add_Click({ try { if ((Get-CurrentMode) -eq 'vless') { $v = ConvertFrom-VlessLink $txtProxy.Text Save-CurrentConfig | Out-Null Write-Log "VLESS сохранён: $($v.VHost):$($v.Port) [$($v.Remark)]" } else { $p = Parse-Proxy $txtProxy.Text Save-CurrentConfig | Out-Null Write-Log "прокси сохранён: $($p.Arg)" } if ($script:SwitchOn) { Write-Log 'ВНИМАНИЕ: режим активен со старой настройкой — переключи рубильник, чтобы применить.' } Update-Ui -CheckReachable } catch { [System.Windows.Forms.MessageBox]::Show($_.Exception.Message, 'Ошибка', 'OK', 'Error') | Out-Null } }) $rbVless.Add_CheckedChanged({ $mode = Get-CurrentMode Apply-ModeLayout $mode $c = Get-Config; $c.mode = $mode; Save-Config $c Update-Ui -CheckReachable }) $btnXray.Add_Click({ if ($script:Busy) { return } try { Set-Busy $true Stop-Xray if (Test-XrayPresent) { Remove-Item $script:XrayExe -Force -ErrorAction SilentlyContinue } Write-Log 'готовлю Xray-core...' Ensure-Xray ${function:Write-Log} Update-XrayStatus Write-Log 'Xray готов.' } catch { Write-Log ('ОШИБКА: ' + $_.Exception.Message) [System.Windows.Forms.MessageBox]::Show($_.Exception.Message, 'Ошибка Xray', 'OK', 'Error') | Out-Null } finally { Set-Busy $false } }) $timer = New-Object System.Windows.Forms.Timer $timer.Interval = 4000 $timer.Add_Tick({ if (-not $script:Busy) { Update-Ui } }) $timer.Start() $form.Add_Shown({ $cfg = Get-Config if ($null -ne $cfg.failClosed) { $chkFailClosed.Checked = [bool]$cfg.failClosed } if ($cfg.mode -eq 'vless') { $rbVless.Checked = $true } else { $rbProxy.Checked = $true } Apply-ModeLayout (Get-CurrentMode) Write-Log 'готово. рубильник управляет проксированием Claude.' Update-Ui -CheckReachable if ($env:CLAUDEPROXY_SMOKE -eq '2') { $bmp = New-Object System.Drawing.Bitmap($form.ClientSize.Width, $form.ClientSize.Height) $form.DrawToBitmap($bmp, (New-Object System.Drawing.Rectangle(0, 0, $bmp.Width, $bmp.Height))) $bmp.Save((Join-Path $Root 'ui-preview.png'), [System.Drawing.Imaging.ImageFormat]::Png) $form.Close() } }) if ($env:CLAUDEPROXY_SMOKE -eq '1') { $smoke = New-Object System.Windows.Forms.Timer $smoke.Interval = 700 $smoke.Add_Tick({ $smoke.Stop(); $form.Close() }) $smoke.Start() } [System.Windows.Forms.Application]::Run($form)