Files
claude-proxy/claude-proxy-gui.ps1
gusadmin ab2d3ed8cd claude-proxy: native C# GUI to force Claude desktop traffic through a proxy
Windows tray "switch" that routes all Claude MSIX-app traffic through a
SOCKS5/HTTP proxy or a VLESS+Reality tunnel (embedded Xray-core), with a
fail-closed Windows Firewall lock. Runs de-elevated so it launches the
user's normal Claude instance; elevates only for firewall changes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 13:46:23 +03:00

629 lines
30 KiB
PowerShell

<#
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)