ab2d3ed8cd
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>
629 lines
30 KiB
PowerShell
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)
|